From 1284af2d9277380712f9ed20112fc894c4825e07 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 2 Apr 2026 16:12:58 -0400 Subject: [PATCH 01/41] Implement FunctionTemplates and FunctionInstances --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 523 ++++++++++++++++-- .../ViewModels/FunctionInstanceViewModel.cs | 206 +++++++ .../ViewModels/FunctionTemplateViewModel.cs | 230 ++++++++ .../ViewModels/ModelSystemEditorViewModel.cs | 496 ++++++++++++++++- .../Views/ModelSystemEditorView.axaml | 118 ++++ .../Views/ModelSystemEditorView.axaml.cs | 21 + src/XTMF2/Bus/Run.cs | 5 + src/XTMF2/Editing/ModelSystemSession.cs | 375 ++++++++++++- src/XTMF2/ModelSystemConstruct/Boundary.cs | 282 ++++++++-- .../ModelSystemConstruct/FunctionInstance.cs | 315 +++++++++++ .../ModelSystemConstruct/FunctionTemplate.cs | 218 +++++++- src/XTMF2/ModelSystemConstruct/GhostNode.cs | 6 +- src/XTMF2/ModelSystemConstruct/MultiLink.cs | 24 +- src/XTMF2/ModelSystemConstruct/Node.cs | 32 +- src/XTMF2/ModelSystemConstruct/NodeHook.cs | 42 ++ src/XTMF2/ModelSystemConstruct/SingleLink.cs | 26 +- .../Editing/TestFunctionInstances.cs | 378 +++++++++++++ .../TestFunctionTemplateBoundaryIsolation.cs | 214 +++++++ .../Editing/TestFunctionTemplateEntryNode.cs | 261 +++++++++ .../TestFunctionTemplateNameUniqueness.cs | 293 ++++++++++ .../XTMF2.UnitTests/Editing/TestFunctions.cs | 46 ++ .../TestGhostNodeInFunctionTemplate.cs | 193 +++++++ 22 files changed, 4195 insertions(+), 109 deletions(-) create mode 100644 src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs create mode 100644 src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs create mode 100644 src/XTMF2/ModelSystemConstruct/FunctionInstance.cs create mode 100644 tests/XTMF2.UnitTests/Editing/TestFunctionInstances.cs create mode 100644 tests/XTMF2.UnitTests/Editing/TestFunctionTemplateBoundaryIsolation.cs create mode 100644 tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs create mode 100644 tests/XTMF2.UnitTests/Editing/TestFunctionTemplateNameUniqueness.cs create mode 100644 tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 962e9d5..267b305 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -32,6 +32,7 @@ You should have received a copy of the GNU General Public License using Avalonia.Styling; using Avalonia.VisualTree; using XTMF2; +using XTMF2.Editing; using XTMF2.GUI.ViewModels; using XTMF2.ModelSystemConstruct; @@ -110,6 +111,35 @@ public sealed class ModelSystemCanvas : Control // Rubber-band (Ctrl+drag) multi-selection rectangle private static readonly IBrush SelectionRectFill = new SolidColorBrush(Color.FromArgb(0x2E, 0x44, 0x88, 0xFF)); private static readonly DashStyle SelectionRectDash = new DashStyle([5, 4], 0); + + // Function-template container box + private static readonly IBrush FtFill = new SolidColorBrush(Color.FromRgb(0x20, 0x12, 0x38)); + private static readonly IBrush FtHeaderFill = new SolidColorBrush(Color.FromRgb(0x4A, 0x28, 0x6E)); + private static readonly IBrush FtBorderBrush = new SolidColorBrush(Color.FromRgb(0x7B, 0x45, 0xBD)); + private static readonly IBrush FtSelBorderBrush = Brushes.DodgerBlue; + private static readonly IBrush FtTextBrush = new SolidColorBrush(Color.FromRgb(0xDD, 0xCC, 0xFF)); + private static readonly IBrush FtHookTextBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xAA, 0xFF)); + private static readonly IBrush FtCountTextBrush = new SolidColorBrush(Color.FromArgb(0x90, 0xCC, 0xAA, 0xFF)); + private static readonly DashStyle FtBorderDash = new DashStyle([8, 3], 0); + private const double FtHeaderHeight = 28.0; + private const double FtHookRowHeight = 16.0; + private const double FtNameFontSize = 11.0; + private const double FtCornerRadius = 6.0; + // Function-instance box (teal/green palette, solid border to distinguish from template) + private static readonly IBrush FiFill = new SolidColorBrush(Color.FromRgb(0x07, 0x24, 0x24)); + private static readonly IBrush FiHeaderFill = new SolidColorBrush(Color.FromRgb(0x0E, 0x4A, 0x44)); + private static readonly IBrush FiBorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xBF, 0xA5)); + private static readonly IBrush FiSelBorderBrush = new SolidColorBrush(Color.FromRgb(0x64, 0xFF, 0xDA)); + private static readonly IBrush FiTextBrush = new SolidColorBrush(Color.FromRgb(0xB2, 0xFF, 0xF0)); + private static readonly IBrush FiSubTextBrush = new SolidColorBrush(Color.FromArgb(0xB0, 0x80, 0xE8, 0xD0)); + private static readonly IBrush FiHookTextBrush = new SolidColorBrush(Color.FromRgb(0x80, 0xCB, 0xC4)); + private const double FiCornerRadius = 6.0; + // Entry-node highlight: gold ring + label (shown when viewing InternalModules of a FunctionTemplate) + private static readonly IBrush EntryNodeRingBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private static readonly IBrush EntryNodeLabelBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private const double EntryNodeRingExtra = 3.0; // px of expansion each side beyond node rect + private const double EntryNodeRingThick = 2.5; // pen width of the outer ring + private const double EntryNodeLabelFontSize = 8.0; // font size for the "▶ Entry Point" badge // Graph-paper background grid private static readonly Pen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(0x38, 0x55, 0x77, 0xAA)), 0.5); private static readonly Pen GridPenLight = new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xAA, 0xCC)), 0.5); @@ -459,35 +489,43 @@ protected override void OnDataContextChanged(EventArgs e) private void Attach() { if (_vm is null) return; - _vm.Nodes.CollectionChanged += OnCollectionChanged; - _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; + _vm.Nodes.CollectionChanged += OnCollectionChanged; + _vm.Starts.CollectionChanged += OnCollectionChanged; + _vm.Links.CollectionChanged += OnCollectionChanged; + _vm.CommentBlocks.CollectionChanged += OnCollectionChanged; + _vm.GhostNodes.CollectionChanged += OnCollectionChanged; + _vm.FunctionTemplates.CollectionChanged += OnCollectionChanged; + _vm.FunctionInstances.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; + foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged += OnElementPropertyChanged; + foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged += OnElementPropertyChanged; } private void Detach() { if (_vm is null) return; - _vm.Nodes.CollectionChanged -= OnCollectionChanged; - _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; + _vm.Nodes.CollectionChanged -= OnCollectionChanged; + _vm.Starts.CollectionChanged -= OnCollectionChanged; + _vm.Links.CollectionChanged -= OnCollectionChanged; + _vm.CommentBlocks.CollectionChanged -= OnCollectionChanged; + _vm.GhostNodes.CollectionChanged -= OnCollectionChanged; + _vm.FunctionTemplates.CollectionChanged -= OnCollectionChanged; + _vm.FunctionInstances.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; + foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged -= OnElementPropertyChanged; + foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged -= OnElementPropertyChanged; } private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -544,6 +582,11 @@ protected override Size MeasureOverride(Size availableSize) maxX = Math.Max(maxX, c.X + c.Width + 400); maxY = Math.Max(maxY, c.Y + c.Height + 400); } + foreach (var fi in _vm.FunctionInstances) + { + maxX = Math.Max(maxX, fi.X + fi.Width + 400); + maxY = Math.Max(maxY, fi.Y + fi.Height + 400); + } } // Measure the inline editor so Avalonia knows its desired size. if (_editingParamNode is not null) @@ -619,7 +662,9 @@ protected override Size ArrangeOverride(Size finalSize) // Position the name editor over the element header being renamed. if (_editingNameElement is not null) { - _nameEditor.FontSize = NodeFontSize * _scale; + _nameEditor.FontSize = (_editingNameElement is FunctionTemplateViewModel + or FunctionInstanceViewModel + ? FtNameFontSize : NodeFontSize) * _scale; _nameEditor.Arrange(new Rect( _nameEditorX * _scale, _nameEditorY * _scale, @@ -655,6 +700,8 @@ public override void Render(DrawingContext ctx) using (ctx.PushTransform(Matrix.CreateScale(_scale, _scale))) { RenderCommentBlocks(ctx); + RenderFunctionTemplates(ctx); + RenderFunctionInstances(ctx); RenderLinks(ctx); RenderNodes(ctx); RenderGhostNodes(ctx); @@ -738,6 +785,181 @@ private void RenderCommentBlocks(DrawingContext ctx) } } + /// + /// Draws function-template container boxes on the canvas. Each box shows the + /// template name in a violet header, exposed-node hook rows below, and a + /// module-count hint in the body. Double-clicking navigates into the template's + /// InternalModules. + /// + private void RenderFunctionTemplates(DrawingContext ctx) + { + foreach (var ft in _vm!.FunctionTemplates) + { + double rw = ft.Width; + double rh = ft.Height; + var rect = new Rect(ft.X, ft.Y, rw, rh); + + // Shadow + outer border (dashed to distinguish from a regular node or boundary) + var borderBrush = ft.IsSelected ? FtSelBorderBrush : FtBorderBrush; + var border = new Pen(borderBrush, NodeBorderThickness + 0.5, dashStyle: FtBorderDash); + DrawRectShadow(ctx, rect, FtCornerRadius); + ctx.DrawRectangle(FtFill, border, rect, FtCornerRadius, FtCornerRadius); + + // ── Header band ──────────────────────────────────────────────── + // Draw header as a filled rectangle, then overdraw the bottom strip + // with the body fill so we effectively clip the rounded bottom corners. + ctx.DrawRectangle(FtHeaderFill, null, rect, FtCornerRadius, FtCornerRadius); + ctx.DrawRectangle(FtFill, null, + new Rect(ft.X, ft.Y + FtHeaderHeight, rw, rh - FtHeaderHeight)); + + // Re-draw the border on top so the header fill doesn't erase it. + ctx.DrawRectangle(null, border, rect, FtCornerRadius, FtCornerRadius); + + // "⊞ TemplateName" label in the header + var labelText = "\u229e " + ft.Name; + var labelFt = MakeText(labelText, FtNameFontSize, FtTextBrush); + var lx = ft.X + 8.0; + var ly = ft.Y + (FtHeaderHeight - labelFt.Height) / 2.0; + using (ctx.PushClip(new Rect(ft.X + 4, ft.Y, rw - 8, FtHeaderHeight))) + ctx.DrawText(labelFt, new Point(lx, ly)); + + // ── Exposed-node hook rows ───────────────────────────────────── + double rowY = ft.Y + FtHeaderHeight; + foreach (var exposedNode in ft.ExposedNodes) + { + ctx.DrawRectangle(InlineParamRowBg, null, + new Rect(ft.X, rowY, rw, FtHookRowHeight)); + ctx.DrawLine(new Pen(HookDividerBrush, 0.5), + new Point(ft.X, rowY), + new Point(ft.X + rw, rowY)); + + // Dot on the left edge (acts like a hook anchor) + double dotCx = ft.X + HookDotRadius + 4.0; + double dotCy = rowY + FtHookRowHeight / 2.0; + ctx.DrawEllipse(HookConnectedBrush, null, + new Point(dotCx, dotCy), HookDotRadius, HookDotRadius); + + // Node name + var nodeNameFt = MakeText( + exposedNode.Name ?? string.Empty, HookFontSize, FtHookTextBrush); + double textLeft = ft.X + HookDotRadius * 2 + 9.0; + using (ctx.PushClip(new Rect(textLeft, rowY, rw - textLeft + ft.X, FtHookRowHeight))) + ctx.DrawText(nodeNameFt, + new Point(textLeft, rowY + (FtHookRowHeight - nodeNameFt.Height) / 2.0)); + + rowY += FtHookRowHeight; + } + + // ── Body hint: module count ──────────────────────────────────── + double bodyH = ft.Y + rh - rowY; + var moduleCount = ft.UnderlyingTemplate.InternalModules.Modules.Count; + var hint = moduleCount == 0 + ? "Empty — double-click to edit" + : $"{moduleCount} module(s) — double-click to edit"; + var hintFt = MakeText(hint, HookFontSize, FtCountTextBrush); + if (bodyH > FtHookRowHeight) + { + var hintX = ft.X + (rw - hintFt.Width) / 2.0; + var hintY = rowY + (bodyH - hintFt.Height) / 2.0; + using (ctx.PushClip(new Rect(ft.X + 4, rowY, rw - 8, bodyH))) + ctx.DrawText(hintFt, new Point(hintX, hintY)); + } + + // ── Resize grip (bottom-right corner) ───────────────────────── + { + double dotR = 2.0; + double gx = ft.X + rw; + double gy = ft.Y + rh; + for (int d = 0; d < 3; d++) + { + double off = 4.0 + d * 4.0; + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(gx - off + dotR, gy - dotR), dotR, dotR); + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(gx - dotR, gy - off + dotR), dotR, dotR); + } + } + } + } + + /// + /// Draws function-instance boxes. Each box is styled in teal, shows the instance name + /// in the header and the template name as a subtitle, then lists exposed hooks below. + /// + private void RenderFunctionInstances(DrawingContext ctx) + { + foreach (var fi in _vm!.FunctionInstances) + { + double rw = fi.Width; + double rh = fi.Height; + var rect = new Rect(fi.X, fi.Y, rw, rh); + + var borderBrush = fi.IsSelected ? FiSelBorderBrush : FiBorderBrush; + var border = new Pen(borderBrush, NodeBorderThickness); + DrawRectShadow(ctx, rect, FiCornerRadius); + ctx.DrawRectangle(FiFill, border, rect, FiCornerRadius, FiCornerRadius); + + // ── Header band ──────────────────────────────────────────────── + ctx.DrawRectangle(FiHeaderFill, null, rect, FiCornerRadius, FiCornerRadius); + ctx.DrawRectangle(FiFill, null, + new Rect(fi.X, fi.Y + FtHeaderHeight, rw, rh - FtHeaderHeight)); + ctx.DrawRectangle(null, border, rect, FiCornerRadius, FiCornerRadius); + + // "⊡ InstanceName" in header + var labelText = "\u22A1 " + fi.Name; + var labelFtText = MakeText(labelText, FtNameFontSize, FiTextBrush); + var lx = fi.X + 8.0; + var ly = fi.Y + (FtHeaderHeight - labelFtText.Height) / 2.0; + using (ctx.PushClip(new Rect(fi.X + 4, fi.Y, rw - 8, FtHeaderHeight))) + ctx.DrawText(labelFtText, new Point(lx, ly)); + + // Template subtitle (small, muted) at the bottom of the header + var subText = MakeText("[" + fi.TemplateName + "]" + (fi.EntryNodeTypeName.Length > 0 ? " : " + fi.EntryNodeTypeName : ""), HookFontSize, FiSubTextBrush); + var subX = fi.X + rw - subText.Width - 8.0; + var subY = fi.Y + (FtHeaderHeight - subText.Height) / 2.0; + using (ctx.PushClip(new Rect(fi.X + 4, fi.Y, rw - 8, FtHeaderHeight))) + ctx.DrawText(subText, new Point(Math.Max(lx + labelFtText.Width + 4, subX), subY)); + + // ── Exposed hook rows ────────────────────────────────────────── + double rowY = fi.Y + FtHeaderHeight; + foreach (var hookNode in fi.ExposedHooks) + { + ctx.DrawRectangle(InlineParamRowBg, null, + new Rect(fi.X, rowY, rw, FtHookRowHeight)); + ctx.DrawLine(new Pen(HookDividerBrush, 0.5), + new Point(fi.X, rowY), new Point(fi.X + rw, rowY)); + + double dotCx = fi.X + HookDotRadius + 4.0; + double dotCy = rowY + FtHookRowHeight / 2.0; + ctx.DrawEllipse(HookConnectedBrush, null, + new Point(dotCx, dotCy), HookDotRadius, HookDotRadius); + + var hookNameFt = MakeText(hookNode.Name ?? string.Empty, HookFontSize, FiHookTextBrush); + double textLeft = fi.X + HookDotRadius * 2 + 9.0; + using (ctx.PushClip(new Rect(textLeft, rowY, rw - textLeft + fi.X, FtHookRowHeight))) + ctx.DrawText(hookNameFt, + new Point(textLeft, rowY + (FtHookRowHeight - hookNameFt.Height) / 2.0)); + + rowY += FtHookRowHeight; + } + + // ── Resize grip ─────────────────────────────────────────────── + { + double dotR = 2.0; + double gx = fi.X + rw; + double gy = fi.Y + rh; + for (int d = 0; d < 3; d++) + { + double off = 4.0 + d * 4.0; + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(gx - off + dotR, gy - dotR), dotR, dotR); + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(gx - dotR, gy - off + dotR), dotR, dotR); + } + } + } + } + private void RenderLinks(DrawingContext ctx) { foreach (var link in _vm!.Links) @@ -777,10 +999,14 @@ private void RenderLinks(DrawingContext ctx) // For multi-link destinations draw a small 1-based index number // beside the arrowhead so the user can see the hook slot ordering. - if (link.UnderlyingLink is MultiLink ml - && link.Destination is NodeViewModel indexDestNode) + if (link.UnderlyingLink is MultiLink ml) { - int idx = ml.Destinations.IndexOf(indexDestNode.UnderlyingNode); + int idx = -1; + if (link.Destination is NodeViewModel indexDestNode) + idx = ml.Destinations.IndexOf(indexDestNode.UnderlyingNode); + else if (link.Destination is FunctionInstanceViewModel indexDestFi) + idx = ml.Destinations.IndexOf(indexDestFi.UnderlyingInstance); + if (idx >= 0) { var ft = MakeText((idx + 1).ToString(), LinkIndexFontSize, brush); @@ -860,6 +1086,13 @@ private void RenderLinks(DrawingContext ctx) if (midXInHSpan) borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom; } + else if (link.Destination is FunctionInstanceViewModel fiDestH) + { + var dRect = new Rect(fiDestH.X, fiDestH.Y, fiDestH.Width, fiDestH.Height); + midXInHSpan = midX >= dRect.X && midX <= dRect.Right; + if (midXInHSpan) + borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom; + } if (midXInHSpan) { @@ -906,6 +1139,13 @@ private void RenderLinks(DrawingContext ctx) if (midYInVSpan) borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right; } + else if (link.Destination is FunctionInstanceViewModel fiDestV) + { + var dRect = new Rect(fiDestV.X, fiDestV.Y, fiDestV.Width, fiDestV.Height); + midYInVSpan = midY >= dRect.Y && midY <= dRect.Bottom; + if (midYInVSpan) + borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right; + } if (midYInVSpan) { @@ -969,6 +1209,12 @@ private void RenderLinks(DrawingContext ctx) return ClipLineToRect(other, rect); } + if (element is FunctionInstanceViewModel fivm) + { + var rect = new Rect(fivm.X, fivm.Y, fivm.Width, fivm.Height); + return ClipLineToRect(other, rect); + } + if (element is StartViewModel) { var center = new Point(element.CenterX, element.CenterY); @@ -1256,6 +1502,8 @@ private double ElementRenderWidth(ICanvasElement el) => el is NodeViewModel nvm ? NodeRenderWidth(nvm) : el is CommentBlockViewModel cvm ? cvm.Width : el is GhostNodeViewModel gnvm ? gnvm.Width + : el is FunctionTemplateViewModel ftvm ? ftvm.Width + : el is FunctionInstanceViewModel fivm ? fivm.Width : 0; /// Returns the rendered height of any resizable canvas element. @@ -1263,6 +1511,8 @@ private double ElementRenderHeight(ICanvasElement el) => el is NodeViewModel nvm ? NodeRenderHeight(nvm) : el is CommentBlockViewModel cvm ? cvm.Height : el is GhostNodeViewModel gnvm ? gnvm.Height + : el is FunctionTemplateViewModel ftvm ? ftvm.Height + : el is FunctionInstanceViewModel fivm ? fivm.Height : 0; private void RenderNodes(DrawingContext ctx) @@ -1275,19 +1525,50 @@ private void RenderNodes(DrawingContext ctx) double rw = NodeRenderWidth(node); double rh = NodeRenderHeight(node); var rect = new Rect(node.X, node.Y, rw, rh); + + // Determine whether this node is the designated entry point of the current template. + bool isEntryNode = _vm.IsInsideFunctionTemplate + && _vm.CurrentFunctionTemplate?.UnderlyingTemplate.EntryNode == node.UnderlyingNode; + var border = new Pen(node.IsSelected ? NodeSelBrush : NodeBorderBrush, NodeBorderThickness); // Node background + border DrawRectShadow(ctx, rect, NodeCornerRadius); ctx.DrawRectangle(NodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); + // ── Entry-node gold ring (drawn over the normal border) ─────────── + if (isEntryNode) + { + var outerRect = new Rect( + node.X - EntryNodeRingExtra, node.Y - EntryNodeRingExtra, + rw + EntryNodeRingExtra * 2, rh + EntryNodeRingExtra * 2); + ctx.DrawRectangle(null, + new Pen(EntryNodeRingBrush, EntryNodeRingThick), + outerRect, + NodeCornerRadius + EntryNodeRingExtra, + NodeCornerRadius + EntryNodeRingExtra); + } + // ── Header: node name centred in the header band ────────────── double headerBottom = node.Y + NodeHeaderHeight; var ft = MakeText(node.Name, NodeFontSize, NodeTextBrush); - var tx = node.X + (rw - ft.Width) / 2; - var ty = node.Y + (NodeHeaderHeight - ft.Height) / 2; + double tx = node.X + (rw - ft.Width) / 2; + // Shift name to the upper portion of the header when a badge will be drawn below it. + double ty = isEntryNode + ? node.Y + 3.0 + : node.Y + (NodeHeaderHeight - ft.Height) / 2; ctx.DrawText(ft, new Point(tx, ty)); + // ── "▶ Entry Point" badge in the lower portion of the header ────── + if (isEntryNode) + { + var badge = MakeText("▶ Entry Point", EntryNodeLabelFontSize, EntryNodeLabelBrush); + double blx = node.X + (rw - badge.Width) / 2.0; + double bly = node.Y + NodeHeaderHeight - badge.Height - 2.5; + using (ctx.PushClip(new Rect(node.X + 2, node.Y, rw - 4, NodeHeaderHeight))) + ctx.DrawText(badge, new Point(blx, bly)); + } + // ── Resize handle (bottom-right corner) ─────────────────────── // Three small diagonal dots — standard grip indicator. { @@ -1813,6 +2094,25 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) e.Handled = true; return; } + + // ── Double-click on a function template: navigate into it ───── + var ftHit = HitTest(mpos, testComments: false) as FunctionTemplateViewModel; + if (ftHit is not null) + { + _vm.NavigateIntoFunctionTemplate(ftHit); + e.Handled = true; + return; + } + + // ── Double-click on a function instance: begin inline rename ── + var fiHit = HitTest(mpos, testComments: false) as FunctionInstanceViewModel; + if (fiHit is not null) + { + _vm.SelectElementCommand.Execute(fiHit); + BeginNameEdit(fiHit); + e.Handled = true; + return; + } } // For right-click (link creation) we exclude comment blocks; for all other paths we include them. @@ -1968,6 +2268,10 @@ protected override void OnPointerMoved(PointerEventArgs e) resizingComment.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); else if (_resizing is GhostNodeViewModel resizingGhost) resizingGhost.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + else if (_resizing is FunctionTemplateViewModel resizingFt) + resizingFt.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + else if (_resizing is FunctionInstanceViewModel resizingFi) + resizingFi.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); InvalidateAndMeasure(); e.Handled = true; return; @@ -2024,6 +2328,8 @@ protected override void OnPointerMoved(PointerEventArgs e) else if (el is StartViewModel gsvm) gsvm.MoveToPreview(nx, ny); else if (el is CommentBlockViewModel gcvm) gcvm.MoveToPreview(nx, ny); else if (el is GhostNodeViewModel ggvm) ggvm.MoveToPreview(nx, ny); + else if (el is FunctionTemplateViewModel gftvm) gftvm.MoveToPreview(nx, ny); + else if (el is FunctionInstanceViewModel gfivm) gfivm.MoveToPreview(nx, ny); } } else @@ -2035,6 +2341,8 @@ protected override void OnPointerMoved(PointerEventArgs e) if (_dragging is StartViewModel svm) svm.MoveToPreview(newX, newY); if (_dragging is CommentBlockViewModel cvm) cvm.MoveToPreview(newX, newY); if (_dragging is GhostNodeViewModel gvm) gvm.MoveToPreview(newX, newY); + if (_dragging is FunctionTemplateViewModel ftvm) ftvm.MoveToPreview(newX, newY); + if (_dragging is FunctionInstanceViewModel fivm) fivm.MoveToPreview(newX, newY); } InvalidateAndMeasure(); @@ -2075,11 +2383,15 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) if (_vm is not null) { - var pos = e.GetCurrentPoint(this).Position; - var dest = HitTest(ToCanvasPos(pos), testComments: false) as NodeViewModel; - // A start is never a valid destination; dest must be a NodeViewModel. - if (dest is not null && !ReferenceEquals(dest, origin)) + var pos = e.GetCurrentPoint(this).Position; + var hit = HitTest(ToCanvasPos(pos), testComments: false); + // Accepts NodeViewModel or FunctionInstanceViewModel as a link destination. + if (hit is NodeViewModel dest && !ReferenceEquals(dest, origin)) _ = _vm.CreateLinkAsync(origin, dest); + else if (hit is FunctionInstanceViewModel fiDest + && !ReferenceEquals(fiDest, origin) + && fiDest.UnderlyingInstance.Template.EntryNode is not null) + _ = _vm.CreateLinkAsync(origin, fiDest); } e.Handled = true; @@ -2096,6 +2408,10 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) committingComment.CommitResize(); else if (_resizing is GhostNodeViewModel committingGhost) committingGhost.CommitResize(); + else if (_resizing is FunctionTemplateViewModel committingFt) + committingFt.CommitResize(); + else if (_resizing is FunctionInstanceViewModel committingFi) + committingFi.CommitResize(); _resizing = null; e.Pointer.Capture(null); Cursor = Cursor.Default; @@ -2186,6 +2502,8 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) else if (el is StartViewModel gsvm) gsvm.CommitMove(); else if (el is CommentBlockViewModel gcvm) gcvm.CommitMove(); else if (el is GhostNodeViewModel ggvm) ggvm.CommitMove(); + else if (el is FunctionTemplateViewModel gftvm) gftvm.CommitMove(); + else if (el is FunctionInstanceViewModel gfivm) gfivm.CommitMove(); } } else @@ -2194,6 +2512,8 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) else if (_dragging is StartViewModel svm) svm.CommitMove(); else if (_dragging is CommentBlockViewModel cvm) cvm.CommitMove(); else if (_dragging is GhostNodeViewModel gvm) gvm.CommitMove(); + else if (_dragging is FunctionTemplateViewModel ftvm) ftvm.CommitMove(); + else if (_dragging is FunctionInstanceViewModel fivm) fivm.CommitMove(); } _dragging = null; @@ -2390,6 +2710,77 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) menu.Items.Add(moveGhostItem); } + // ── Function template – specific items ───────────────────────────── + if (element is FunctionTemplateViewModel capturedFt) + { + // Enter: navigate into the template's InternalModules + var enterItem = new MenuItem { Header = "Edit Contents (double-click)" }; + enterItem.Click += (_, _) => + { + vm.NavigateIntoFunctionTemplate(capturedFt); + InvalidateAndMeasure(); + }; + + // Rename + var renameItem = new MenuItem { Header = "Rename…" }; + renameItem.Click += async (_, _) => + { + await vm.RenameFunctionTemplateAsync(capturedFt); + InvalidateAndMeasure(); + }; + + menu.Items.Add(new Separator()); + menu.Items.Add(enterItem); + menu.Items.Add(renameItem); + } + + // ── Function instance – specific items ───────────────────────────── + if (element is FunctionInstanceViewModel capturedFi) + { + var renameItem = new MenuItem { Header = "Rename…" }; + renameItem.Click += async (_, _) => + { + await vm.RenameFunctionInstanceAsync(capturedFi); + InvalidateAndMeasure(); + }; + + menu.Items.Add(new Separator()); + menu.Items.Add(renameItem); + } + + // ── "Expose as hook" option — when we're inside a function template ── + if (_vm.IsInsideFunctionTemplate && element is NodeViewModel exposeCandidateNode + && exposeCandidateNode.IsBasicParameter) + { + bool alreadyExposed = _vm.CurrentFunctionTemplate?.ExposedNodes.Contains(exposeCandidateNode.UnderlyingNode) ?? false; + var exposeHeader = alreadyExposed ? "Remove from Exposed Hooks" : "Expose as Hook on Template"; + var exposeItem = new MenuItem { Header = exposeHeader }; + exposeItem.Click += async (_, _) => + { + await vm.ToggleFunctionTemplateExposedNodeAsync(exposeCandidateNode); + InvalidateAndMeasure(); + }; + menu.Items.Add(new Separator()); + menu.Items.Add(exposeItem); + } + + // ── "Set as Entry Node" — any node while viewing InternalModules ───────── + if (_vm.IsInsideFunctionTemplate && element is NodeViewModel entryNodeCandidate) + { + var currentEntry = _vm.CurrentFunctionTemplate?.UnderlyingTemplate.EntryNode; + bool alreadyEntry = ReferenceEquals(currentEntry, entryNodeCandidate.UnderlyingNode); + var entryHeader = alreadyEntry ? "Clear Entry Node" : "Set as Entry Node"; + var capturedEntryCandidate = entryNodeCandidate; + var entryItem = new MenuItem { Header = entryHeader }; + entryItem.Click += async (_, _) => + { + await vm.SetFunctionTemplateEntryNodeAsync(capturedEntryCandidate); + InvalidateAndMeasure(); + }; + menu.Items.Add(new Separator()); + menu.Items.Add(entryItem); + } + menu.Items.Add(deleteItem); ContextMenu = menu; @@ -2901,6 +3292,20 @@ private void BeginNameEdit(ICanvasElement element) _nameEditorW = svm.Diameter + 20; _nameEditorH = NodeHeaderHeight; } + else if (element is FunctionTemplateViewModel ftvm) + { + _nameEditorX = ftvm.X; + _nameEditorY = ftvm.Y; + _nameEditorW = ftvm.Width; + _nameEditorH = FtHeaderHeight; + } + else if (element is FunctionInstanceViewModel fivm) + { + _nameEditorX = fivm.X; + _nameEditorY = fivm.Y; + _nameEditorW = fivm.Width; + _nameEditorH = FtHeaderHeight; + } else { return; @@ -2927,12 +3332,17 @@ private void CommitNameEdit() _nameEditor.IsVisible = false; if (!string.IsNullOrWhiteSpace(name)) { - _ = element switch + CommandError? renameError = null; + bool ok = element switch { - NodeViewModel nvm => nvm.SetName(name, out _), - StartViewModel svm => svm.SetName(name, out _), - _ => true, + NodeViewModel nvm => nvm.SetName(name, out _), + StartViewModel svm => svm.SetName(name, out _), + FunctionTemplateViewModel ftvm => ftvm.SetName(name, out renameError), + FunctionInstanceViewModel fivm => fivm.SetName(name, out renameError), + _ => true, }; + if (!ok) + _vm?.ShowToast(renameError?.Message ?? "Failed to rename.", isError: true, durationMs: 4000); } InvalidateAndMeasure(); } @@ -2973,7 +3383,8 @@ private void OnNameEditorLostFocus(object? sender, Avalonia.Interactivity.Routed /// public void BeginNameEditForSelected() { - if (_vm?.SelectedElement is NodeViewModel or StartViewModel) + if (_vm?.SelectedElement is NodeViewModel or StartViewModel + or FunctionTemplateViewModel or FunctionInstanceViewModel) BeginNameEdit(_vm.SelectedElement); } @@ -3097,6 +3508,26 @@ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.Rou if (handle.Contains(pos)) return ghost; } + foreach (var ft in _vm.FunctionTemplates) + { + var handle = new Rect( + ft.X + ft.Width - ResizeHandleSize, + ft.Y + ft.Height - ResizeHandleSize, + ResizeHandleSize, + ResizeHandleSize); + if (handle.Contains(pos)) + return ft; + } + foreach (var fi in _vm.FunctionInstances) + { + var handle = new Rect( + fi.X + fi.Width - ResizeHandleSize, + fi.Y + fi.Height - ResizeHandleSize, + ResizeHandleSize, + ResizeHandleSize); + if (handle.Contains(pos)) + return fi; + } return null; } @@ -3224,6 +3655,20 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) return ghost; } + // Function-template containers (behind nodes but above comment blocks) + foreach (var ft in _vm.FunctionTemplates) + { + if (new Rect(ft.X, ft.Y, ft.Width, ft.Height).Contains(pos)) + return ft; + } + + // Function-instance boxes (between function templates and comment blocks) + foreach (var fi in _vm.FunctionInstances) + { + if (new Rect(fi.X, fi.Y, fi.Width, fi.Height).Contains(pos)) + return fi; + } + // Comment blocks (background layer) if (testComments) { diff --git a/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs new file mode 100644 index 0000000..fd6d078 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs @@ -0,0 +1,206 @@ +/* + 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.ObjectModel; +using System.Collections.Specialized; +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. +/// A FunctionInstance is rendered as a rectangular node whose header shows the instance +/// name and whose hook rows correspond to the referenced +/// . +/// +public sealed partial class FunctionInstanceViewModel : ObservableObject, ICanvasElement +{ + /// The underlying model object. + public FunctionInstance UnderlyingInstance { get; } + + private readonly ModelSystemSession _session; + private readonly User _user; + + // ── Drag / resize preview offsets ───────────────────────────────────── + private double? _previewX; + private double? _previewY; + private double? _previewW; + private double? _previewH; + + // ── ICanvasElement ──────────────────────────────────────────────────── + public double X => _previewX ?? (double)UnderlyingInstance.Location.X; + public double Y => _previewY ?? (double)UnderlyingInstance.Location.Y; + + /// Rendered width; defaults to 120 when the stored value is 0. + public double Width => _previewW ?? (UnderlyingInstance.Location.Width is 0 ? 120.0 : (double)UnderlyingInstance.Location.Width); + + /// Rendered height; defaults to 50 when the stored value is 0. + public double Height => _previewH ?? (UnderlyingInstance.Location.Height is 0 ? 50.0 : (double)UnderlyingInstance.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; + + /// + /// The short display name of the referenced + /// shown beneath the instance name in the header band. + /// + public string TemplateName => UnderlyingInstance.Template.Name; + + /// + /// The short name of the entry-node type for the referenced template, + /// or an empty string when the template has no entry node. + /// Displayed alongside the template name in the instance header. + /// + public string EntryNodeTypeName + => UnderlyingInstance.Template.Type?.Name ?? string.Empty; + + /// + /// Live-synced list of exposed hook nodes derived from the referenced template. + /// Kept in sync with . + /// + public ObservableCollection ExposedHooks { get; } = new(); + + public FunctionInstanceViewModel(FunctionInstance instance, ModelSystemSession session, User user) + { + UnderlyingInstance = instance; + _session = session; + _user = user; + _name = instance.Name; + + ((INotifyPropertyChanged)instance).PropertyChanged += OnModelPropertyChanged; + ((INotifyPropertyChanged)instance.Template).PropertyChanged += OnTemplatePropertyChanged; + + // Track the template's exposed nodes so the canvas hook rows stay current. + SyncExposedHooks(); + ((INotifyCollectionChanged)instance.Template.ExposedNodes).CollectionChanged += OnExposedNodesChanged; + } + + private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(FunctionInstance.Name): + Name = UnderlyingInstance.Name; + break; + case nameof(FunctionInstance.Location): + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + break; + } + } + + private void OnTemplatePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(FunctionTemplate.Type)) + OnPropertyChanged(nameof(EntryNodeTypeName)); + } + + private void OnExposedNodesChanged(object? sender, NotifyCollectionChangedEventArgs e) + => SyncExposedHooks(); + + private void SyncExposedHooks() + { + ExposedHooks.Clear(); + foreach (var n in UnderlyingInstance.Template.ExposedNodes) + ExposedHooks.Add(n); + } + + // ── Drag support ────────────────────────────────────────────────────── + + public void MoveToPreview(double x, double y) + { + _previewX = x; + _previewY = y; + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + } + + public void CommitMove() + { + if (_previewX is null) return; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + MoveTo(x, y); + } + + public void MoveTo(double x, double y) + { + var loc = UnderlyingInstance.Location; + var w = loc.Width is 0 ? 120f : loc.Width; + var h = loc.Height is 0 ? 50f : loc.Height; + _session.SetFunctionInstanceLocation(_user, UnderlyingInstance, + new Rectangle((float)x, (float)y, w, h), out _); + } + + // ── Resize support ──────────────────────────────────────────────────── + + public void ResizeToPreview(double w, double h) + { + _previewW = Math.Max(120.0, w); + _previewH = Math.Max(50.0, h); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + } + + public void CommitResize() + { + if (_previewW is null) return; + var w = _previewW.Value; + var h = _previewH!.Value; + _previewW = null; + _previewH = null; + var loc = UnderlyingInstance.Location; + _session.SetFunctionInstanceLocation(_user, UnderlyingInstance, + new Rectangle(loc.X, loc.Y, (float)w, (float)h), out _); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + } + + /// + /// Renames the instance via the session (supports undo/redo). + /// Returns false and populates on failure. + /// + public bool SetName(string name, out CommandError? error) + => _session.RenameFunctionInstance(_user, UnderlyingInstance, name, out error); + + /// Detaches model event subscriptions (call before discarding this VM). + public void Detach() + { + ((INotifyPropertyChanged)UnderlyingInstance).PropertyChanged -= OnModelPropertyChanged; + ((INotifyPropertyChanged)UnderlyingInstance.Template).PropertyChanged -= OnTemplatePropertyChanged; + ((INotifyCollectionChanged)UnderlyingInstance.Template.ExposedNodes).CollectionChanged -= OnExposedNodesChanged; + } +} diff --git a/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs new file mode 100644 index 0000000..99ef48d --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs @@ -0,0 +1,230 @@ +/* + 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.ObjectModel; +using System.Collections.Specialized; +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. +/// +/// Function templates are rendered as named container boxes (indigo/violet) within the +/// boundary where they are defined. Double-clicking them drills into the template's +/// boundary. Nodes within +/// InternalModules that were marked as exposed–hooks appear as hook rows on the +/// container box when viewed from the parent boundary. +/// +/// +public sealed partial class FunctionTemplateViewModel : ObservableObject, ICanvasElement +{ + /// The underlying model object. + public FunctionTemplate UnderlyingTemplate { get; } + + private readonly ModelSystemSession _session; + private readonly User _user; + + // ── Drag-preview offsets ────────────────────────────────────────────── + private double? _previewX; + private double? _previewY; + private double? _previewW; + private double? _previewH; + + // ── ICanvasElement coordinates ──────────────────────────────────────── + /// + public double X => _previewX ?? (double)UnderlyingTemplate.Location.X; + + /// + public double Y => _previewY ?? (double)UnderlyingTemplate.Location.Y; + + /// Rendered width; defaults to 200 when the stored value is 0. + public double Width => _previewW ?? (UnderlyingTemplate.Location.Width is 0 ? 200.0 : (double)UnderlyingTemplate.Location.Width); + + /// Rendered height; defaults to 120 when the stored value is 0. + public double Height => _previewH ?? (UnderlyingTemplate.Location.Height is 0 ? 120.0 : (double)UnderlyingTemplate.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; + + /// + /// The short name of the entry-node type, or an empty string when no entry node is set. + /// Displayed on the template's canvas container box. + /// + public string EntryNodeTypeName + => UnderlyingTemplate.Type?.Name ?? string.Empty; + + // ── Exposed-node mirrors (synced from model) ────────────────────────── + /// + /// Live list of nodes from that are + /// exposed as external hooks on this template's canvas container box. + /// + public ObservableCollection ExposedNodes { get; } = new(); + + public FunctionTemplateViewModel(FunctionTemplate template, ModelSystemSession session, User user) + { + UnderlyingTemplate = template; + _session = session; + _user = user; + _name = template.Name; + + // Sync from the model on property changes. + ((INotifyPropertyChanged)template).PropertyChanged += OnModelPropertyChanged; + + // Sync exposed-nodes collection. + SyncExposedNodes(); + ((INotifyCollectionChanged)template.ExposedNodes).CollectionChanged += OnExposedNodesChanged; + } + + private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(FunctionTemplate.Name): + Name = UnderlyingTemplate.Name; + break; + case nameof(FunctionTemplate.Type): + OnPropertyChanged(nameof(EntryNodeTypeName)); + break; + case nameof(FunctionTemplate.Location): + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + break; + } + } + + private void OnExposedNodesChanged(object? sender, NotifyCollectionChangedEventArgs e) + => SyncExposedNodes(); + + private void SyncExposedNodes() + { + ExposedNodes.Clear(); + foreach (var n in UnderlyingTemplate.ExposedNodes) + ExposedNodes.Add(n); + } + + // ── Drag support ────────────────────────────────────────────────────── + + /// + /// Updates the visual position without persisting to the session (for drag preview). + /// Call on mouse-up to persist. + /// + public void MoveToPreview(double x, double y) + { + _previewX = x; + _previewY = y; + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + } + + /// + /// Commits the current preview position to the session and clears the preview. + /// Does nothing when no preview is active. + /// + public void CommitMove() + { + if (_previewX is null) return; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + MoveTo(x, y); + } + + /// + /// Moves the template container to a new canvas position, persisting via the session + /// (supports undo/redo). + /// + public void MoveTo(double x, double y) + { + var loc = UnderlyingTemplate.Location; + var w = loc.Width is 0 ? 200f : loc.Width; + var h = loc.Height is 0 ? 120f : loc.Height; + _session.SetFunctionTemplateLocation(_user, UnderlyingTemplate, + new Rectangle((float)x, (float)y, w, h), out _); + } + + // ── Resize support ──────────────────────────────────────────────────── + + /// + /// Updates the visual size without persisting to the session (for resize-drag preview). + /// Call on mouse-up to persist. + /// Width is clamped to ≥ 140; height to ≥ 60. + /// + public void ResizeToPreview(double w, double h) + { + _previewW = Math.Max(140.0, w); + _previewH = Math.Max(60.0, h); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + } + + /// + /// Commits the current preview size to the session and clears the preview. + /// Does nothing when no preview is active. + /// + public void CommitResize() + { + if (_previewW is null) return; + var w = _previewW.Value; + var h = _previewH!.Value; + _previewW = null; + _previewH = null; + var loc = UnderlyingTemplate.Location; + _session.SetFunctionTemplateLocation(_user, UnderlyingTemplate, + new Rectangle(loc.X, loc.Y, (float)w, (float)h), out _); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + } + + // ── ICanvasElement: not needed for FunctionTemplates but satisfies the interface ─ + // (Name is already an ObservableProperty above, IsSelected likewise.) + + /// + /// Renames the template via the session (supports undo/redo). + /// Returns false on failure and populates . + /// + public bool SetName(string name, out CommandError? error) + => _session.RenameFunctionTemplate(_user, UnderlyingTemplate, name, out error); + + /// Detaches model event subscriptions (call before discarding this VM). + public void Detach() + { + ((INotifyPropertyChanged)UnderlyingTemplate).PropertyChanged -= OnModelPropertyChanged; + ((INotifyCollectionChanged)UnderlyingTemplate.ExposedNodes).CollectionChanged -= OnExposedNodesChanged; + } +} diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index e3d8a07..e8e5787 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -93,6 +93,9 @@ public string CurrentBoundaryLabel var parts = new System.Collections.Generic.List(); var b = _currentBoundary; while (b is not null) { parts.Insert(0, b.Name); b = b.Parent; } + // When inside a function template's InternalModules, append the template indicator. + if (_currentFunctionTemplate is { } ft) + parts[^1] = $"[{ft.Name}]"; return string.Join(" › ", parts); } } @@ -100,6 +103,19 @@ public string CurrentBoundaryLabel /// true when the current boundary is the global (root) boundary. public bool IsAtRootBoundary => ReferenceEquals(_currentBoundary, GlobalBoundary); + // ── Function-template navigation ────────────────────────────────────── + /// + /// The function template whose is currently + /// being shown on the canvas, or null when viewing a regular boundary. + /// + private FunctionTemplateViewModel? _currentFunctionTemplate; + + /// true when the canvas is showing the inside of a function template. + public bool IsInsideFunctionTemplate => _currentFunctionTemplate is not null; + + /// The function template currently being edited inside, or null when on a regular boundary. + public FunctionTemplateViewModel? CurrentFunctionTemplate => _currentFunctionTemplate; + /// Items shown in the boundary navigation dropdown. public ObservableCollection BoundaryNavigationItems { get; } = new(); @@ -126,6 +142,12 @@ public string CurrentBoundaryLabel /// Observable wrappers around . public ObservableCollection GhostNodes { get; } = new(); + /// Observable wrappers around . + public ObservableCollection FunctionTemplates { get; } = new(); + + /// Observable wrappers around . + public ObservableCollection FunctionInstances { get; } = new(); + /// Observable view-models for the model system's variable list. public ObservableCollection ModelSystemVariables { get; } = new(); @@ -247,9 +269,54 @@ public bool SelectedElementIsParameter StartViewModel => "Start (entry point)", NodeViewModel nvm => $"Module\n{nvm.TypeName}", CommentBlockViewModel => "Comment Block", + FunctionTemplateViewModel => "Function Template", + FunctionInstanceViewModel => "Function Instance", _ => string.Empty }; + /// True when the selected element is a . + public bool SelectedElementIsFunctionTemplate => SelectedElement is FunctionTemplateViewModel; + + /// True when the selected element is a . + public bool SelectedElementIsFunctionInstance => SelectedElement is FunctionInstanceViewModel; + + /// + /// The name of the template referenced by the currently selected function instance, + /// or an empty string when nothing (or a non-instance element) is selected. + /// + public string SelectedFunctionInstanceTemplateName + => (SelectedElement as FunctionInstanceViewModel)?.TemplateName ?? string.Empty; + + /// + /// The exposed hook nodes of the template referenced by the currently selected + /// function instance, or an empty collection. + /// + public System.Collections.Generic.IEnumerable SelectedFunctionInstanceExposedHooks + => (SelectedElement as FunctionInstanceViewModel)?.ExposedHooks + ?? System.Linq.Enumerable.Empty(); + + /// + /// true when the selected function instance's template has no exposed hook nodes — + /// drives the "(none)" hint text in the properties panel. + /// + public bool SelectedFunctionInstanceHasNoExposedHooks + => SelectedElement is not FunctionInstanceViewModel fi || fi.ExposedHooks.Count == 0; + + /// + /// The exposed-node list of the currently selected function template, or an empty list + /// when nothing (or a non-template element) is selected. Bound by the toolbox panel. + /// + public IEnumerable SelectedFunctionTemplateExposedNodes + => (SelectedElement as FunctionTemplateViewModel)?.ExposedNodes + ?? System.Linq.Enumerable.Empty(); + + /// + /// true when the selected function template has no exposed hook nodes — + /// drives the "(none)" hint text in the properties panel. + /// + public bool SelectedFunctionTemplateHasNoExposedNodes + => SelectedElement is not FunctionTemplateViewModel ft || ft.ExposedNodes.Count == 0; + /// All module types currently registered in the runtime. public System.Collections.ObjectModel.ReadOnlyObservableCollection AvailableModuleTypes => Session.LoadedModuleTypes; @@ -290,6 +357,13 @@ partial void OnSelectedElementChanged(ICanvasElement? value) OnPropertyChanged(nameof(SelectedElementIsComment)); OnPropertyChanged(nameof(SelectedElementIsNotComment)); OnPropertyChanged(nameof(SelectedElementIsParameter)); + OnPropertyChanged(nameof(SelectedElementIsFunctionTemplate)); + OnPropertyChanged(nameof(SelectedFunctionTemplateExposedNodes)); + OnPropertyChanged(nameof(SelectedFunctionTemplateHasNoExposedNodes)); + OnPropertyChanged(nameof(SelectedElementIsFunctionInstance)); + OnPropertyChanged(nameof(SelectedFunctionInstanceTemplateName)); + OnPropertyChanged(nameof(SelectedFunctionInstanceExposedHooks)); + OnPropertyChanged(nameof(SelectedFunctionInstanceHasNoExposedHooks)); SelectedElementParameterValue = value is NodeViewModel pnvm && pnvm.IsParameterNode ? pnvm.ParameterValueRepresentation @@ -371,9 +445,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. + // Ghost nodes and FunctionInstances must be populated before links so that + // ResolveElement can find their view-models when wiring up link destinations. foreach (var ghost in boundary.GhostNodes) GhostNodes.Add(new GhostNodeViewModel(ghost, Session, User)); + foreach (var ft in boundary.FunctionTemplates) FunctionTemplates.Add(new FunctionTemplateViewModel(ft, Session, User)); + foreach (var fi in boundary.FunctionInstances) FunctionInstances.Add(new FunctionInstanceViewModel(fi, Session, User)); foreach (var link in boundary.Links) TryAddLinkViewModel(link); foreach (var cb in boundary.CommentBlocks) CommentBlocks.Add(new CommentBlockViewModel(cb, Session, User)); } @@ -389,6 +465,8 @@ private void SubscribeToBoundary(Boundary boundary) ((INotifyCollectionChanged)boundary.Links).CollectionChanged += OnLinksChanged; ((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged += OnCommentBlocksChanged; ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged += OnGhostNodesChanged; + ((INotifyCollectionChanged)boundary.FunctionTemplates).CollectionChanged += OnFunctionTemplatesChanged; + ((INotifyCollectionChanged)boundary.FunctionInstances).CollectionChanged += OnFunctionInstancesChanged; // Keep the same wrapper instance so we can correctly remove the handler later. _subscribedChildBoundaries = boundary.Boundaries; @@ -402,6 +480,8 @@ private void UnsubscribeFromBoundary(Boundary boundary) ((INotifyCollectionChanged)boundary.Links).CollectionChanged -= OnLinksChanged; ((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged -= OnCommentBlocksChanged; ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged -= OnGhostNodesChanged; + ((INotifyCollectionChanged)boundary.FunctionTemplates).CollectionChanged -= OnFunctionTemplatesChanged; + ((INotifyCollectionChanged)boundary.FunctionInstances).CollectionChanged -= OnFunctionInstancesChanged; if (_subscribedChildBoundaries is not null) { @@ -432,11 +512,23 @@ public void SwitchToBoundary(Boundary boundary) Links.Clear(); CommentBlocks.Clear(); GhostNodes.Clear(); + foreach (var ft in FunctionTemplates) ft.Detach(); + FunctionTemplates.Clear(); + foreach (var fi in FunctionInstances) fi.Detach(); + FunctionInstances.Clear(); _currentBoundary = boundary; OnPropertyChanged(nameof(CurrentBoundary)); OnPropertyChanged(nameof(CurrentBoundaryLabel)); OnPropertyChanged(nameof(IsAtRootBoundary)); + // If the new boundary is not the InternalModules of the tracked template, leave FT mode. + if (_currentFunctionTemplate is not null + && !ReferenceEquals(boundary, _currentFunctionTemplate.UnderlyingTemplate.InternalModules)) + { + _currentFunctionTemplate = null; + OnPropertyChanged(nameof(IsInsideFunctionTemplate)); + ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + } SubscribeToBoundary(_currentBoundary); BuildFromBoundary(_currentBoundary); @@ -548,6 +640,42 @@ private void OnGhostNodesChanged(object? sender, NotifyCollectionChangedEventArg } } + private void OnFunctionTemplatesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null) + foreach (FunctionTemplate ft in e.NewItems) + FunctionTemplates.Add(new FunctionTemplateViewModel(ft, Session, User)); + + if (e.OldItems is not null) + foreach (FunctionTemplate ft in e.OldItems) + { + var vm = FunctionTemplates.FirstOrDefault(v => v.UnderlyingTemplate == ft); + if (vm is not null) + { + vm.Detach(); + FunctionTemplates.Remove(vm); + } + } + } + + private void OnFunctionInstancesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null) + foreach (FunctionInstance fi in e.NewItems) + FunctionInstances.Add(new FunctionInstanceViewModel(fi, Session, User)); + + if (e.OldItems is not null) + foreach (FunctionInstance fi in e.OldItems) + { + var vm = FunctionInstances.FirstOrDefault(v => v.UnderlyingInstance == fi); + if (vm is not null) + { + vm.Detach(); + FunctionInstances.Remove(vm); + } + } + } + private void TryAddLinkViewModel(Link link) { var originElement = ResolveElement(link.Origin); @@ -604,6 +732,8 @@ private void OnMultiLinkDestinationsChanged( return GhostNodes.FirstOrDefault(g => g.UnderlyingGhostNode == ghost); if (node is Start start) return Starts.FirstOrDefault(s => s.UnderlyingStart == start); + if (node is FunctionInstance fi) + return FunctionInstances.FirstOrDefault(fivm => fivm.UnderlyingInstance == fi); 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. @@ -895,6 +1025,68 @@ internal async Task MoveGhostNodeToBoundaryAsync(GhostNodeViewModel gvm) /// presents a when there are multiple options. /// Called directly by the canvas (not a RelayCommand because it requires typed parameters). /// + /// + /// Creates a link whose destination is a . + /// The FunctionInstance acts as a node whose type is its template's EntryNode type. + /// + public async Task CreateLinkAsync(ICanvasElement originElement, FunctionInstanceViewModel destFi) + { + if (ParentWindow is null) return; + + var destNode = destFi.UnderlyingInstance; // FunctionInstance : Node + var destType = destNode.Type; // Template.EntryNode?.Type ?? typeof(object) + + if (destType == typeof(object)) + { + await ShowError("No Entry Node", + new CommandError($"'{destFi.Name}' has no entry node designated and cannot be used as a link destination.")); + return; + } + + // Resolve the underlying origin node. + Node? originNode = originElement switch + { + NodeViewModel nvm => nvm.UnderlyingNode, + StartViewModel svm => svm.UnderlyingStart, + _ => null + }; + if (originNode is null) return; + + var compatible = new List(); + foreach (var hook in originNode.Hooks) + { + Type hookElementType = (hook.Cardinality is HookCardinality.AtLeastOne or HookCardinality.AnyNumber) + ? (hook.Type.GetElementType() ?? hook.Type) + : hook.Type; + if (hookElementType.IsAssignableFrom(destType)) + compatible.Add(hook); + } + + if (compatible.Count == 0) + { + await ShowError("Incompatible Types", + new CommandError($"No hooks on '{originNode.Name}' are compatible with '{destFi.Name}' (type '{destType.Name}').")); + return; + } + + NodeHook selectedHook; + if (compatible.Count == 1) + { + selectedHook = compatible[0]; + } + else + { + var dialog = new HookPickerDialog(compatible, + $"Select which hook on '{originNode.Name}' to connect to '{destFi.Name}':"); + await dialog.ShowDialog(ParentWindow); + if (dialog.WasCancelled || dialog.SelectedHook is null) return; + selectedHook = dialog.SelectedHook; + } + + if (!Session.AddLink(User, originNode, selectedHook, destNode, out _, out var error)) + await ShowError("Create Link Failed", error); + } + public async Task CreateLinkAsync(ICanvasElement originElement, NodeViewModel destVm) { if (ParentWindow is null) return; @@ -1304,6 +1496,286 @@ private void AddCommentBlock() Session.AddCommentBlock(User, _currentBoundary, $"Comment {++_commentCounter}", location, out _, out _); } + // ── Function-template commands ───────────────────────────────────────── + + /// + /// Prompts for a name and creates a new in the + /// current boundary. The template is given a default canvas position near the + /// top-left of the existing content. + /// + [RelayCommand] + private async Task AddFunctionTemplate() + { + if (ParentWindow is null) return; + + var dialog = new InputDialog( + title: "Add Function Template", + prompt: "Enter the function template name:", + defaultText: $"FunctionTemplate{FunctionTemplates.Count + 1}"); + await dialog.ShowDialog(ParentWindow); + + var name = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || dialog.WasCancelled) return; + + // Place it in the upper area of the canvas. + var offset = FunctionTemplates.Count * 60; + var ftLocation = new Rectangle(60f + offset % 600, 60f + (offset / 600) * 160f, 220f, 140f); + + if (!Session.AddFunctionTemplate(User, _currentBoundary, name, + out var ft, out var error)) + { + await ShowError("Add Function Template Failed", error); + return; + } + + // Set canvas position (the default location set by the model constructor is fine, + // but we override with a better-spread position). + Session.SetFunctionTemplateLocation(User, ft!, ftLocation, out _); + } + + /// + /// Prompts for a new name and renames the given function template. + /// + public async Task RenameFunctionTemplateAsync(FunctionTemplateViewModel ftvm) + { + if (ParentWindow is null) return; + + var dialog = new InputDialog( + title: "Rename Function Template", + prompt: "Enter the new name:", + defaultText: ftvm.Name); + await dialog.ShowDialog(ParentWindow); + + var newName = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(newName) || dialog.WasCancelled) return; + if (newName == ftvm.Name) return; + + if (!Session.RenameFunctionTemplate(User, ftvm.UnderlyingTemplate, newName, out var error)) + await ShowError("Rename Function Template Failed", error); + } + + /// + /// Deletes the given function template from the current boundary (with undo support). + /// + public async Task DeleteFunctionTemplateAsync(FunctionTemplateViewModel ftvm) + { + if (!Session.RemoveFunctionTemplate(User, _currentBoundary, ftvm.UnderlyingTemplate, out var error)) + await ShowError("Delete Function Template Failed", error); + } + + // ── Function-instance commands ───────────────────────────────────────── + + /// + /// Prompts the user to pick a from the current boundary + /// and a name, then places a new on the canvas. + /// + [RelayCommand] + private async Task AddFunctionInstance() + { + if (ParentWindow is null) return; + + var availableTemplates = new List(); + _currentBoundary.CollectAccessibleFunctionTemplates(availableTemplates); + if (availableTemplates.Count == 0) + { + ShowToast("No function templates are defined in this boundary or its children.", isError: true, durationMs: 4000); + return; + } + + // Build display names that include the child-boundary path when needed. + var templateDisplayNames = availableTemplates + .Select(ft => Boundary.GetQualifiedTemplateName(_currentBoundary, ft) ?? ft.Name) + .ToList(); + + // Pick a template (skip the picker if only one template exists). + FunctionTemplate selectedTemplate; + if (availableTemplates.Count == 1) + { + selectedTemplate = availableTemplates[0]; + } + else + { + var picker = new StartPickerDialog( + title: "Add Function Instance", + prompt: "Select the function template to instantiate:", + startNames: templateDisplayNames, + defaultStart: templateDisplayNames[0]); + await picker.ShowDialog(ParentWindow); + if (picker.WasCancelled) return; + var picked = picker.SelectedStartName ?? templateDisplayNames[0]; + var pickedIdx = templateDisplayNames.IndexOf(picked); + selectedTemplate = availableTemplates[pickedIdx >= 0 ? pickedIdx : 0]; + } + + // Ask for the instance name. + var nameDialog = new InputDialog( + title: "Add Function Instance", + prompt: $"Enter the instance name ({selectedTemplate.Name}):", + defaultText: $"{selectedTemplate.Name}{FunctionInstances.Count + 1}"); + await nameDialog.ShowDialog(ParentWindow); + + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + var offset = FunctionInstances.Count * 40; + var location = new Rectangle(80f + offset % 800, 80f + (offset / 800) * 100f, 160f, 70f); + + if (!Session.AddFunctionInstance(User, _currentBoundary, selectedTemplate, name, location, + out _, out var error)) + { + await ShowError("Add Function Instance Failed", error); + } + } + + /// + /// Renames the given function instance after prompting the user for a new name. + /// + public async Task RenameFunctionInstanceAsync(FunctionInstanceViewModel fivm) + { + if (ParentWindow is null) return; + + var dialog = new InputDialog( + title: "Rename Function Instance", + prompt: "Enter the new name:", + defaultText: fivm.Name); + await dialog.ShowDialog(ParentWindow); + + var newName = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(newName) || dialog.WasCancelled) return; + if (newName == fivm.Name) return; + + if (!Session.RenameFunctionInstance(User, fivm.UnderlyingInstance, newName, out var error)) + await ShowError("Rename Function Instance Failed", error); + } + + /// + /// Deletes the given function instance from the current boundary (with undo support). + /// + public async Task DeleteFunctionInstanceAsync(FunctionInstanceViewModel fivm) + { + if (!Session.RemoveFunctionInstance(User, fivm.UnderlyingInstance, out var error)) + await ShowError("Delete Function Instance Failed", error); + } + + /// + /// Navigates the canvas into 's + /// boundary so the user can + /// edit the nodes contained within the function template. + /// + public void NavigateIntoFunctionTemplate(FunctionTemplateViewModel ftvm) + { + _currentFunctionTemplate = ftvm; + OnPropertyChanged(nameof(IsInsideFunctionTemplate)); + ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + SwitchToBoundary(ftvm.UnderlyingTemplate.InternalModules); + } + + /// + /// ICommand wrapper for so the AXAML + /// properties panel can bind to it via CommandParameter="{Binding SelectedElement}". + /// + [RelayCommand] + private void NavigateIntoFunctionTemplateBinding(object? parameter) + { + if (parameter is FunctionTemplateViewModel ftvm) + NavigateIntoFunctionTemplate(ftvm); + } + + /// + /// ICommand wrapper for usable from AXAML + /// with CommandParameter="{Binding SelectedElement}". + /// + [RelayCommand] + private async Task RenameFunctionTemplateBinding(object? parameter) + { + if (parameter is FunctionTemplateViewModel ftvm) + await RenameFunctionTemplateAsync(ftvm); + } + + /// + /// ICommand wrapper for usable from AXAML + /// with CommandParameter="{Binding SelectedElement}". + /// + [RelayCommand] + private async Task DeleteFunctionTemplateBinding(object? parameter) + { + if (parameter is FunctionTemplateViewModel ftvm) + await DeleteFunctionTemplateAsync(ftvm); + } + + /// + /// ICommand wrapper for usable from AXAML + /// with CommandParameter="{Binding SelectedElement}". + /// + [RelayCommand] + private async Task RenameFunctionInstanceBinding(object? parameter) + { + if (parameter is FunctionInstanceViewModel fivm) + await RenameFunctionInstanceAsync(fivm); + } + + /// + /// ICommand wrapper for usable from AXAML + /// with CommandParameter="{Binding SelectedElement}". + /// + [RelayCommand] + private async Task DeleteFunctionInstanceBinding(object? parameter) + { + if (parameter is FunctionInstanceViewModel fivm) + await DeleteFunctionInstanceAsync(fivm); + } + + /// + /// Exits the current function template's + /// and returns the canvas to the parent boundary. + /// + [RelayCommand(CanExecute = nameof(IsInsideFunctionTemplate))] + private void ExitFunctionTemplate() + { + if (_currentFunctionTemplate is null) return; + var parentBoundary = _currentFunctionTemplate.UnderlyingTemplate.Parent; + _currentFunctionTemplate = null; + OnPropertyChanged(nameof(IsInsideFunctionTemplate)); + ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + SwitchToBoundary(parentBoundary); + } + + /// + /// Toggles whether (a node in the current + /// ) is exposed as an external hook + /// on the template's canvas container. + /// + /// Only available when the canvas is inside a function template (i.e. + /// is true). + /// + /// + public async Task ToggleFunctionTemplateExposedNodeAsync(NodeViewModel nvm) + { + if (_currentFunctionTemplate is null) return; + + if (!Session.ToggleFunctionTemplateExposedNode( + User, _currentFunctionTemplate.UnderlyingTemplate, nvm.UnderlyingNode, out var error)) + await ShowError("Toggle Exposed Hook Failed", error); + } + + /// + /// Designates 's underlying node as the + /// of the current function template. + /// If the node is already the entry node the assignment is cleared instead + /// (i.e. this method toggles the entry-node designation). + /// + public async Task SetFunctionTemplateEntryNodeAsync(NodeViewModel nvm) + { + if (_currentFunctionTemplate is null) return; + var template = _currentFunctionTemplate.UnderlyingTemplate; + // Toggle: clear when the node is already the entry node, otherwise assign it. + var newEntry = ReferenceEquals(template.EntryNode, nvm.UnderlyingNode) + ? null + : nvm.UnderlyingNode; + if (!Session.SetFunctionTemplateEntryNode(User, template, newEntry, out var error)) + await ShowError("Set Entry Node Failed", error); + } + /// /// Prompts for a run name and start to execute, then submits the run to the /// . @@ -1668,6 +2140,7 @@ public async Task DeleteMultipleAsync(IReadOnlyList elements) NodeViewModel nvm => Session.RemoveNode(User, nvm.UnderlyingNode, out err), StartViewModel svm => Session.RemoveStart(User, svm.UnderlyingStart, out err), CommentBlockViewModel cvm => Session.RemoveCommentBlock(User, _currentBoundary, cvm.UnderlyingBlock, out err), + FunctionTemplateViewModel ftvm => Session.RemoveFunctionTemplate(User, _currentBoundary, ftvm.UnderlyingTemplate, out err), _ => true, }; if (!ok && err is not null) @@ -1700,6 +2173,23 @@ private async Task DeleteSelected() SelectElement(null); success = Session.RemoveCommentBlock(User, _currentBoundary, cvm.UnderlyingBlock, out error); } + else if (SelectedElement is FunctionTemplateViewModel ftvm) + { + SelectElement(null); + await DeleteFunctionTemplateAsync(ftvm); + return; + } + else if (SelectedElement is FunctionInstanceViewModel fivm) + { + SelectElement(null); + await DeleteFunctionInstanceAsync(fivm); + return; + } + else if (SelectedElement is GhostNodeViewModel ghostVm) + { + SelectElement(null); + success = Session.RemoveGhostNode(User, ghostVm.UnderlyingGhostNode, out error); + } else if (SelectedLink is { } lvm) { // If a specific destination is selected in the panel, remove just that entry. @@ -1747,6 +2237,8 @@ public void Dispose() foreach (var varVm in ModelSystemVariables) varVm.Detach(); + foreach (var ft in FunctionTemplates) ft.Detach(); + UnsubscribeFromBoundary(_currentBoundary); foreach (var lvm in Links) lvm.Detach(); diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 474db91..1a394d2 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -161,6 +161,26 @@ HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/> + + private void OnRenameFunctionTemplateClick(object? sender, RoutedEventArgs e) + { + TheCanvas.BeginNameEditForSelected(); + TheCanvas.Focus(); + } + + /// + /// Handles the "Rename" button click in the function-instance properties panel + /// by starting an inline header edit on the canvas (instead of a modal dialog). + /// + private void OnRenameFunctionInstanceClick(object? sender, RoutedEventArgs e) + { + TheCanvas.BeginNameEditForSelected(); + TheCanvas.Focus(); + } + private void OnParameterValueEditBoxKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.Enter) diff --git a/src/XTMF2/Bus/Run.cs b/src/XTMF2/Bus/Run.cs index e718f84..48094a9 100644 --- a/src/XTMF2/Bus/Run.cs +++ b/src/XTMF2/Bus/Run.cs @@ -268,6 +268,11 @@ private bool RuntimeValidation(ref string? moduleName, ref string? errorMessage) } } } + foreach (var fi in current.FunctionInstances) + { + if (!fi.ValidateRuntimeModules(ref moduleName, ref errorMessage)) + return false; + } } return true; } diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 628e72c..48016e6 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -909,6 +909,19 @@ public bool MoveNodeToBoundary(User user, Node node, Boundary targetBoundary, var oldBoundary = node.ContainedWithin!; if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; } + // A node that lives inside a FunctionTemplate's InternalModules may only be moved + // to other boundaries within the *same* FunctionTemplate, never outside it. + var oldFt = FindOwningFunctionTemplate(ModelSystem.GlobalBoundary, oldBoundary); + var newFt = FindOwningFunctionTemplate(ModelSystem.GlobalBoundary, targetBoundary); + if (!ReferenceEquals(oldFt, newFt)) + { + error = new CommandError( + oldFt is not null + ? $"Node '{node.Name}' is contained within FunctionTemplate '{oldFt.Name}' and cannot be moved outside of it." + : $"Cannot move a node from the global scope into FunctionTemplate '{newFt!.Name}'."); + return false; + } + // Outgoing links live in the origin node's boundary and must follow the node. var outgoingLinks = oldBoundary.Links.Where(l => l.Origin == node).ToList(); @@ -1012,6 +1025,18 @@ public bool MoveGhostNodeToBoundary(User user, GhostNode ghostNode, Boundary tar var oldBoundary = ghostNode.ContainedWithin!; if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; } + // Ghost nodes are subject to the same FunctionTemplate scope restriction as nodes. + var oldFt = FindOwningFunctionTemplate(ModelSystem.GlobalBoundary, oldBoundary); + var newFt = FindOwningFunctionTemplate(ModelSystem.GlobalBoundary, targetBoundary); + if (!ReferenceEquals(oldFt, newFt)) + { + error = new CommandError( + oldFt is not null + ? $"Ghost node '{ghostNode.Name}' is contained within FunctionTemplate '{oldFt.Name}' and cannot be moved outside of it." + : $"Cannot move a ghost node from the global scope into FunctionTemplate '{newFt!.Name}'."); + return false; + } + if (!oldBoundary.RemoveGhostNode(ghostNode, out error)) return false; ghostNode.UpdateContainedWithin(targetBoundary); @@ -1118,6 +1143,14 @@ void RestoreGhostCascade() if (variableIndex >= 0) ModelSystem.Variables.RemoveAt(variableIndex); + // If the node lives inside a FunctionTemplate's InternalModules and was exposed + // as a hook, remove it from the exposed list so the template stays consistent. + var owningTemplate = boundary.Parent?.FunctionTemplates + .FirstOrDefault(ft => ft.InternalModules == boundary); + bool wasExposedInTemplate = owningTemplate is not null && owningTemplate.ExposedNodes.Contains(node); + if (wasExposedInTemplate) + owningTemplate!.RemoveExposedNode(node); + if (boundary.RemoveNode(node, out error)) { Buffer.AddUndo(new Command(() => @@ -1134,6 +1167,8 @@ void RestoreGhostCascade() var restoreIdx = Math.Min(variableIndex, ModelSystem.Variables.Count); ModelSystem.Variables.Insert(restoreIdx, node); } + if (wasExposedInTemplate) + owningTemplate!.AddExposedNode(node); return (true, null); } return (false, e); @@ -1145,6 +1180,8 @@ void RestoreGhostCascade() foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); ModelSystem.Variables.Remove(node); + if (wasExposedInTemplate) + owningTemplate!.RemoveExposedNode(node); return (boundary.RemoveNode(node, out var e), e); })); return true; @@ -1161,6 +1198,8 @@ void RestoreGhostCascade() var restoreIdx = Math.Min(variableIndex, ModelSystem.Variables.Count); ModelSystem.Variables.Insert(restoreIdx, node); } + if (wasExposedInTemplate) + owningTemplate!.AddExposedNode(node); return false; } } @@ -1336,6 +1375,28 @@ public bool SetNodeLocation(User user, Node mss, Rectangle newLocation, [NotNull } } + /// + /// Walks the entire boundary tree rooted at and returns the + /// whose + /// subtree contains , or when the + /// boundary belongs to the global (non-FunctionTemplate) scope. + /// + private static FunctionTemplate? FindOwningFunctionTemplate(Boundary root, Boundary boundary) + { + foreach (var ft in root.FunctionTemplates) + { + if (ReferenceEquals(ft.InternalModules, boundary) || ft.InternalModules.Contains(boundary)) + return ft; + } + foreach (var child in root.Boundaries) + { + var result = FindOwningFunctionTemplate(child, boundary); + if (result is not null) + return result; + } + return null; + } + private List GetLinksGoingTo(Node destNode) { var ret = new List(); @@ -1440,6 +1501,11 @@ private List GetAllGhostNodesOf(Node realNode) var current = stack.Pop(); foreach (var child in current.Boundaries) stack.Push(child); + // Also traverse the InternalModules of every FunctionTemplate in this + // boundary — they are not exposed through Boundaries and would otherwise + // be missed, leaving orphaned ghost nodes after the real node is deleted. + foreach (var ft in current.FunctionTemplates) + stack.Push(ft.InternalModules); foreach (var ghost in current.GhostNodes) if (ghost.ReferencedNode == realNode) result.Add(ghost); @@ -1868,6 +1934,11 @@ public bool AddFunctionTemplate(User user, Boundary boundary, string functionTem error = new CommandError("The user does not have access to this project.", true); return false; } + if (ModelSystem.GlobalBoundary.ContainsFunctionTemplateName(functionTemplateName)) + { + error = new CommandError($"A function template named '{functionTemplateName}' already exists in the model system."); + return false; + } if (!boundary.AddFunctionTemplate(functionTemplateName, out var template, out error)) { return false; @@ -1923,13 +1994,313 @@ public bool RemoveFunctionTemplate(User user, Boundary boundary, FunctionTemplat } } + /// + /// Renames a function template within a boundary. + /// + /// The user issuing the command. + /// The function template to rename. + /// The new name. Must not be null or whitespace. + /// An error message if the operation fails. + /// True if the operation succeeds, false otherwise with an error message. + public bool RenameFunctionTemplate(User user, FunctionTemplate template, string newName, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + error = null; + if (string.IsNullOrWhiteSpace(newName)) + { + error = new CommandError("A function template name must not be empty."); + return false; + } + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var oldName = template.Name; + if (!string.Equals(oldName, newName, StringComparison.Ordinal) + && ModelSystem.GlobalBoundary.ContainsFunctionTemplateName(newName)) + { + error = new CommandError($"A function template named '{newName}' already exists in the model system."); + return false; + } + template.Name = newName; + Buffer.AddUndo(new Command(() => + { + template.Name = oldName; + return (true, null); + }, () => + { + template.Name = newName; + return (true, null); + })); + return true; + } + } + + /// + /// Moves or resizes a function template's canvas container box. + /// + /// The user issuing the command. + /// The function template whose location is being updated. + /// The new canvas location rectangle. + /// An error message if the operation fails. + /// True if the operation succeeds, false otherwise with an error message. + public bool SetFunctionTemplateLocation(User user, FunctionTemplate template, Rectangle newLocation, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + error = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var oldLocation = template.Location; + template.SetLocation(newLocation); + Buffer.AddUndo(new Command(() => + { + template.SetLocation(oldLocation); + return (true, null); + }, () => + { + template.SetLocation(newLocation); + return (true, null); + })); + return true; + } + } + + /// + /// Designates as the + /// of , or clears it when is null. + /// + /// The entry node must be a that belongs to + /// 's . + /// It defines the runtime type of the template so that + /// objects can participate in hook-compatibility checks. + /// + /// + /// The user issuing the command. + /// The function template to modify. + /// The start node to use as the entry point, or null to clear. + /// An error description when the method returns false. + /// true on success; false with a populated on failure. + public bool SetFunctionTemplateEntryNode(User user, FunctionTemplate template, Node? entryNode, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + error = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + // Validate: the entry node (when non-null) must live in InternalModules. + if (entryNode != null && entryNode.ContainedWithin != template.InternalModules) + { + error = new CommandError( + $"The node '{entryNode.Name}' does not belong to the InternalModules of template '{template.Name}'."); + return false; + } + var oldEntryNode = template.EntryNode; + template.SetEntryNode(entryNode); + Buffer.AddUndo(new Command(() => + { + template.SetEntryNode(oldEntryNode); + return (true, null); + }, () => + { + template.SetEntryNode(entryNode); + return (true, null); + })); + return true; + } + } + + /// + /// Toggles whether a node within a function template's InternalModules is exposed + /// as an external hook on the template's canvas container. + /// Adds the node when it is not yet exposed; removes it when it already is. + /// + /// The user issuing the command. + /// The function template that owns the node. + /// A node in 's InternalModules. + /// An error message if the operation fails. + /// True if the operation succeeds, false otherwise with an error message. + public bool ToggleFunctionTemplateExposedNode(User user, FunctionTemplate template, Node node, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(node); + error = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + bool wasExposed = template.ExposedNodes.Contains(node); + if (!template.ToggleExposedNode(node, out error)) + return false; + Buffer.AddUndo(new Command(() => + { + if (wasExposed) template.AddExposedNode(node); + else template.RemoveExposedNode(node); + return (true, null); + }, () => + { + if (wasExposed) template.RemoveExposedNode(node); + else template.AddExposedNode(node); + return (true, null); + })); + return true; + } + } + + // ── FunctionInstance ────────────────────────────────────────────── + + /// + /// Places a new of into + /// . + /// + public bool AddFunctionInstance(User user, Boundary boundary, FunctionTemplate template, + string name, Rectangle location, + out FunctionInstance? instance, [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(boundary); + ArgumentNullException.ThrowIfNull(template); + instance = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + if (!boundary.AddFunctionInstance(name, template, location, out instance, out error)) + return false; + var captured = instance!; + Buffer.AddUndo(new Command(() => + { + return (boundary.RemoveFunctionInstance(captured, out var e), e); + }, () => + { + return (boundary.AddFunctionInstance(captured, out var e), e); + })); + return true; + } + } + + /// + /// Removes from its containing boundary. + /// + public bool RemoveFunctionInstance(User user, FunctionInstance instance, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(instance); + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var boundary = instance.ContainedWithin; + if (!boundary.RemoveFunctionInstance(instance, out error)) + return false; + Buffer.AddUndo(new Command(() => + { + return (boundary.AddFunctionInstance(instance, out var e), e); + }, () => + { + return (boundary.RemoveFunctionInstance(instance, out var e), e); + })); + return true; + } + } + + /// + /// Renames . + /// + public bool RenameFunctionInstance(User user, FunctionInstance instance, string newName, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(instance); + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var oldName = instance.Name; + if (!instance.SetName(newName, out error)) + return false; + Buffer.AddUndo(new Command(() => + { + instance.SetName(oldName, out _); + return (true, null); + }, () => + { + instance.SetName(newName, out _); + return (true, null); + })); + return true; + } + } + + /// + /// Moves / resizes a on the canvas. + /// + public bool SetFunctionInstanceLocation(User user, FunctionInstance instance, Rectangle newLocation, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(instance); + error = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var oldLocation = instance.Location; + instance.SetLocation(newLocation); + Buffer.AddUndo(new Command(() => + { + instance.SetLocation(oldLocation); + return (true, null); + }, () => + { + instance.SetLocation(newLocation); + return (true, null); + })); + return true; + } + } + /// /// Create a model system session to use for a run /// /// The XTMF runtime the run will occur in /// - internal static ModelSystemSession CreateRunSession(ProjectSession session, ModelSystem modelSystem) - { + internal static ModelSystemSession CreateRunSession(ProjectSession session, ModelSystem modelSystem) { return new ModelSystemSession(session, modelSystem); } diff --git a/src/XTMF2/ModelSystemConstruct/Boundary.cs b/src/XTMF2/ModelSystemConstruct/Boundary.cs index 73c1f9c..43d3b0f 100644 --- a/src/XTMF2/ModelSystemConstruct/Boundary.cs +++ b/src/XTMF2/ModelSystemConstruct/Boundary.cs @@ -53,6 +53,7 @@ public sealed class Boundary : INotifyPropertyChanged private const string CommentBlocksProperty = "CommentBlocks"; private const string FunctionTemplateProperty = "FunctionTemplates"; private const string GhostNodesProperty = "GhostNodes"; + private const string FunctionInstancesProperty = "FunctionInstances"; /// /// This lock must be obtained before changing any local settings. @@ -65,18 +66,31 @@ public sealed class Boundary : INotifyPropertyChanged private readonly ObservableCollection _commentBlocks = new ObservableCollection(); private readonly ObservableCollection _functionTemplates = new ObservableCollection(); private readonly ObservableCollection _ghostNodes = new ObservableCollection(); + private readonly ObservableCollection _functionInstances = new ObservableCollection(); + + // Cached read-only wrappers — must be the same instance on every access so that + // subscribe/unsubscribe pairs in the GUI always refer to the identical object. + private ReadOnlyObservableCollection? _modulesView; + private ReadOnlyObservableCollection? _startsView; + private ReadOnlyObservableCollection? _boundariesView; + private ReadOnlyObservableCollection? _linksView; + private ReadOnlyObservableCollection? _functionTemplatesView; + private ReadOnlyObservableCollection? _ghostNodesView; + private ReadOnlyObservableCollection? _functionInstancesView; /// /// Get readonly access to the links contained in this boundary. /// - public ReadOnlyObservableCollection Links => new ReadOnlyObservableCollection(_links); + public ReadOnlyObservableCollection Links + => _linksView ??= 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); + public ReadOnlyObservableCollection GhostNodes + => _ghostNodesView ??= new ReadOnlyObservableCollection(_ghostNodes); /// /// Create a new boundary, optionally with a parent @@ -111,7 +125,9 @@ internal bool Contains(Boundary boundary) { throw new ArgumentNullException(nameof(boundary)); } - return _boundaries.Any(b => b == boundary || b.Contains(boundary)); + return _boundaries.Any(b => b == boundary || b.Contains(boundary)) + || _functionTemplates.Any(ft => + ft.InternalModules == boundary || ft.InternalModules.Contains(boundary)); } public event PropertyChangedEventHandler? PropertyChanged; @@ -120,40 +136,20 @@ internal bool Contains(Boundary boundary) /// Provides a readonly view of the locally contained modules. /// public ReadOnlyObservableCollection Modules - { - get - { - lock (_writeLock) - { - return new ReadOnlyObservableCollection(_modules); - } - } - } + => _modulesView ??= new ReadOnlyObservableCollection(_modules); /// /// Provides a readonly view of the locally contained Starts. /// public ReadOnlyObservableCollection Starts - { - get - { - lock (_writeLock) - { - return new ReadOnlyObservableCollection(_starts); - } - } - } + => _startsView ??= new ReadOnlyObservableCollection(_starts); public ReadOnlyObservableCollection FunctionTemplates - { - get - { - lock(_writeLock) - { - return new ReadOnlyObservableCollection(_functionTemplates); - } - } - } + => _functionTemplatesView ??= new ReadOnlyObservableCollection(_functionTemplates); + + /// Read-only view of the objects placed in this boundary. + public ReadOnlyObservableCollection FunctionInstances + => _functionInstancesView ??= new ReadOnlyObservableCollection(_functionInstances); internal bool Validate(ref string? moduleName, ref string? error) { @@ -171,6 +167,12 @@ internal bool Validate(ref string? moduleName, ref string? error) return false; } } + // Validate the internal structure of each FunctionTemplate once (shared across all instances). + foreach (var ft in _functionTemplates) + { + if (!ft.InternalModules.Validate(ref moduleName, ref error)) + return false; + } return true; } @@ -197,6 +199,10 @@ static List GetUsedTypes(Boundary current, List included) { GetUsedTypes(child, included); } + foreach (var ft in current._functionTemplates) + { + GetUsedTypes(ft.InternalModules, included); + } return included; } return GetUsedTypes(this, new List()).Select((type, index) => (type, index)) @@ -235,6 +241,11 @@ internal bool ConstructModules(XTMFRuntime runtime, ref string? error) return false; } } + // Construct per-instance runtime modules for each FunctionInstance. + foreach (var fi in _functionInstances) + { + if (!fi.ConstructRuntimeModules(runtime, ref error)) return false; + } error = null; return true; } @@ -345,6 +356,13 @@ internal List GetLinksGoingToBoundary(Boundary boundary) { stack.Push(child); } + // Also traverse the InternalModules of every FunctionTemplate in this + // boundary — they are not part of _boundaries and would otherwise be + // invisible to the search, leaving links inside templates un-cleaned. + foreach (var ft in current._functionTemplates) + { + stack.Push(ft.InternalModules); + } // don't bother analyzing the boundary being removed if (current != boundary) { @@ -461,6 +479,151 @@ internal bool RemoveFunctionTemplate(FunctionTemplate template, [NotNullWhen(fal } } + // ── FunctionInstance ────────────────────────────────────────────── + + internal bool AddFunctionInstance(string name, FunctionTemplate template, Rectangle location, + out FunctionInstance? instance, [NotNullWhen(false)] out CommandError? error) + { + if (string.IsNullOrWhiteSpace(name)) + { + instance = null; + error = new CommandError("A function instance name must not be empty."); + return false; + } + lock (_writeLock) + { + if (_functionInstances.Any(fi => fi.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + instance = null; + error = new CommandError($"A function instance named '{name}' already exists in this boundary."); + return false; + } + instance = new FunctionInstance(name, template, this, location); + _functionInstances.Add(instance); + error = null; + return true; + } + } + + internal bool AddFunctionInstance(FunctionInstance instance, [NotNullWhen(false)] out CommandError? error) + { + lock (_writeLock) + { + if (_functionInstances.Contains(instance)) + { + error = new CommandError("The function instance already exists in this boundary."); + return false; + } + _functionInstances.Add(instance); + error = null; + return true; + } + } + + internal bool RemoveFunctionInstance(FunctionInstance instance, [NotNullWhen(false)] out CommandError? error) + { + lock (_writeLock) + { + if (!_functionInstances.Remove(instance)) + { + error = new CommandError("The function instance does not exist in this boundary."); + return false; + } + error = null; + return true; + } + } + + // ── Accessible FunctionTemplate helpers ─────────────────────────── + + /// + /// Returns when is already used by any + /// reachable from this boundary (i.e. here or in any + /// descendant ). Does NOT descend into + /// sub-boundaries. + /// + public bool ContainsFunctionTemplateName(string name) + { + if (_functionTemplates.Any(ft => ft.Name.Equals(name, StringComparison.Ordinal))) + return true; + foreach (var child in _boundaries) + { + if (child.ContainsFunctionTemplateName(name)) + return true; + } + return false; + } + + /// + /// Collects all objects accessible from this boundary: + /// those defined directly here and in any descendant (recursive). + /// Does not descend into sub-boundaries. + /// + public void CollectAccessibleFunctionTemplates(List results) + { + foreach (var ft in _functionTemplates) + results.Add(ft); + foreach (var child in _boundaries) + child.CollectAccessibleFunctionTemplates(results); + } + + /// + /// Returns the qualified name of relative to . + /// Returns just ft.Name when the template lives directly in ; + /// otherwise returns a slash-separated boundary path (e.g. "ChildA/MyTemplate"). + /// Returns when the template is not reachable from . + /// + public static string? GetQualifiedTemplateName(Boundary root, FunctionTemplate ft) + { + var path = GetBoundaryRelativePath(root, ft.Parent); + if (path is null) return null; + return path.Length == 0 ? ft.Name : path + "/" + ft.Name; + } + + private static string? GetBoundaryRelativePath(Boundary root, Boundary target) + { + if (root == target) return string.Empty; + foreach (var child in root._boundaries) + { + var childPath = GetBoundaryRelativePath(child, target); + if (childPath != null) + return childPath.Length == 0 ? child.Name : child.Name + "/" + childPath; + } + return null; + } + + /// + /// Resolves a by qualified name relative to . + /// A plain name (e.g. "MyTemplate") resolves within itself; + /// a slash-prefixed name (e.g. "ChildA/MyTemplate") navigates to the named child boundary first. + /// + public static FunctionTemplate? ResolveTemplate(Boundary root, string qualifiedName) + { + var lastSlash = qualifiedName.LastIndexOf('/'); + if (lastSlash < 0) + { + return root._functionTemplates.FirstOrDefault(ft => + ft.Name.Equals(qualifiedName, StringComparison.Ordinal)); + } + var boundaryPath = qualifiedName.Substring(0, lastSlash); + var templateName = qualifiedName.Substring(lastSlash + 1); + var boundary = ResolveBoundary(root, boundaryPath); + if (boundary is null) return null; + return boundary._functionTemplates.FirstOrDefault(ft => + ft.Name.Equals(templateName, StringComparison.Ordinal)); + } + + private static Boundary? ResolveBoundary(Boundary root, string path) + { + if (string.IsNullOrEmpty(path)) return root; + var slashIdx = path.IndexOf('/'); + var childName = slashIdx < 0 ? path : path.Substring(0, slashIdx); + var rest = slashIdx < 0 ? string.Empty : path.Substring(slashIdx + 1); + var child = root._boundaries.FirstOrDefault(b => + b.Name.Equals(childName, StringComparison.Ordinal)); + return child is null ? null : ResolveBoundary(child, rest); + } + internal bool ConstructLinks(ref string? error) { lock (_writeLock) @@ -480,6 +643,11 @@ internal bool ConstructLinks(ref string? error) return false; } } + // Wire the per-instance cloned modules for each FunctionInstance. + foreach (var fi in _functionInstances) + { + if (!fi.ConstructRuntimeLinks(ref error)) return false; + } return true; } } @@ -502,20 +670,15 @@ internal bool ConstructEmptyLinks(ref string? error) return false; } } + // Fill empty AnyNumber hooks on per-instance FunctionInstance modules. + foreach (var fi in _functionInstances) + fi.ConstructEmptyRuntimeLinks(); return true; } } public ReadOnlyObservableCollection Boundaries - { - get - { - lock (_writeLock) - { - return new ReadOnlyObservableCollection(_boundaries); - } - } - } + => _boundariesView ??= new ReadOnlyObservableCollection(_boundaries); /// The parent boundary, or null if this is the root boundary. public Boundary? Parent { get; private set; } @@ -599,6 +762,20 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona child.Save(ref index, nodeDictionary, typeDictionary, writer); } writer.WriteEndArray(); + writer.WritePropertyName(FunctionTemplateProperty); + writer.WriteStartArray(); + foreach(var functionTemplate in FunctionTemplates) + { + functionTemplate.Save(ref index, nodeDictionary, typeDictionary, writer); + } + writer.WriteEndArray(); + writer.WritePropertyName(FunctionInstancesProperty); + writer.WriteStartArray(); + foreach (var fi in _functionInstances) + { + fi.Save(ref index, nodeDictionary, writer); + } + writer.WriteEndArray(); writer.WritePropertyName(LinksProperty); writer.WriteStartArray(); foreach (var link in _links) @@ -613,13 +790,6 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona docBlock.Save(writer); } writer.WriteEndArray(); - writer.WritePropertyName(FunctionTemplateProperty); - writer.WriteStartArray(); - foreach(var functionTemplate in FunctionTemplates) - { - 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); @@ -697,6 +867,8 @@ internal void PreAssignNodeIndices(ref int index, Dictionary nodeDict child.PreAssignNodeIndices(ref index, nodeDictionary); foreach (var ft in _functionTemplates) ft.InternalModules.PreAssignNodeIndices(ref index, nodeDictionary); + foreach (var fi in _functionInstances) + if (!nodeDictionary.ContainsKey(fi)) nodeDictionary[fi] = index++; foreach (var ghost in _ghostNodes) if (!nodeDictionary.ContainsKey(ghost)) nodeDictionary[ghost] = index++; } @@ -873,6 +1045,24 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D } } } + else if (reader.ValueTextEquals(FunctionInstancesProperty)) + { + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) + { + return Helper.FailWith(out error, "Unexpected token when starting to read FunctionInstances for a boundary."); + } + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.Comment) + { + if (!FunctionInstance.Load(ref reader, this, node, out var fi, ref error)) + { + return false; + } + _functionInstances.Add(fi!); + } + } + } else if (reader.ValueTextEquals(GhostNodesProperty)) { if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) diff --git a/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs b/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs new file mode 100644 index 0000000..4be7790 --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs @@ -0,0 +1,315 @@ +/* + Copyright 2026, Travel Modelling Group, 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; +using XTMF2.Editing; + +namespace XTMF2.ModelSystemConstruct +{ + /// + /// Represents an instantiation of a placed on a boundary's + /// canvas. A is a : its + /// mirrors the template's + /// type, making it a valid link destination whenever the entry-node type is compatible with + /// the originating hook. + /// + public sealed class FunctionInstance : Node + { + // ── Additional JSON property names (NameProperty / X / Y / Width / HeightProperty / + // IndexProperty are inherited as protected constants from Node) ───────────── + private const string TemplateNameProperty = "TemplateName"; + + /// + /// The that this is an instantiation of. + /// + public FunctionTemplate Template { get; } + + /// + /// Initialises a new . + /// The underlying is given an empty hooks list (FunctionInstances + /// act as link destinations, never as link origins) and initially typed as + /// ; the actual is computed from + /// at access time. + /// + public FunctionInstance(string name, FunctionTemplate template, Boundary containedWithin, Rectangle location) + : base(name, typeof(object), containedWithin, Array.Empty(), location) + { + Template = template; + // Re-fire our Type property when the template's designated entry node changes. + ((INotifyPropertyChanged)template).PropertyChanged += OnTemplatePropertyChanged; + } + + private void OnTemplatePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(FunctionTemplate.EntryNode) or nameof(FunctionTemplate.Type)) + InvokePropertyChanged(nameof(Type)); + } + + /// + /// The effective module type of this function instance — mirrors the template's + /// type. Returns + /// (no compatible hooks) when no entry node has been designated. + /// + public override Type Type => Template.EntryNode?.Type ?? typeof(object); + + // ── Persistence ─────────────────────────────────────────────────── + + /// + /// Serialises this function instance. The index is + /// written as "Index" so that links targeting this FI can be resolved on load. + /// + internal void Save(ref int index, Dictionary nodeDictionary, Utf8JsonWriter writer) + { + if (!nodeDictionary.TryGetValue(this, out int myIndex)) + { + myIndex = index++; + nodeDictionary[this] = myIndex; + } + writer.WriteStartObject(); + writer.WriteString(NameProperty, Name); + var qualifiedName = Boundary.GetQualifiedTemplateName(ContainedWithin, Template) ?? Template.Name; + writer.WriteString(TemplateNameProperty, qualifiedName); + writer.WriteNumber(IndexProperty, myIndex); + writer.WriteNumber(XProperty, Location.X); + writer.WriteNumber(YProperty, Location.Y); + writer.WriteNumber(WidthProperty, Location.Width); + writer.WriteNumber(HeightProperty, Location.Height); + writer.WriteEndObject(); + } + + /// + /// Loads a from JSON and registers it in + /// so that subsequent link entries can reference it by index. + /// The reader must be positioned at a token. + /// + internal static bool Load(ref Utf8JsonReader reader, Boundary parentBoundary, + Dictionary node, + [NotNullWhen(true)] out FunctionInstance? instance, + [NotNullWhen(false)] ref string? error) + { + instance = null; + if (reader.TokenType != JsonTokenType.StartObject) + { + error = "Unexpected token when reading FunctionInstance."; + return false; + } + + string? name = null; + string? templateName = null; + int fiIndex = -1; + float x = 40, y = 40, w = 120, h = 50; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) continue; + + if (reader.ValueTextEquals(NameProperty)) { reader.Read(); name = reader.GetString(); } + else if (reader.ValueTextEquals(TemplateNameProperty)) { reader.Read(); templateName = reader.GetString(); } + else if (reader.ValueTextEquals(IndexProperty)) { reader.Read(); fiIndex = reader.GetInt32(); } + else if (reader.ValueTextEquals(XProperty)) { reader.Read(); x = reader.GetSingle(); } + else if (reader.ValueTextEquals(YProperty)) { reader.Read(); y = reader.GetSingle(); } + else if (reader.ValueTextEquals(WidthProperty)) { reader.Read(); w = reader.GetSingle(); } + else if (reader.ValueTextEquals(HeightProperty)) { reader.Read(); h = reader.GetSingle(); } + else reader.Skip(); + } + + if (string.IsNullOrWhiteSpace(name)) + { + error = "FunctionInstance is missing its Name."; + return false; + } + if (string.IsNullOrWhiteSpace(templateName)) + { + error = $"FunctionInstance '{name}' is missing its TemplateName."; + return false; + } + + var template = Boundary.ResolveTemplate(parentBoundary, templateName!); + if (template is null) + { + error = $"FunctionInstance '{name}' references unknown FunctionTemplate '{templateName}'."; + return false; + } + + instance = new FunctionInstance(name, template, parentBoundary, new Rectangle(x, y, w, h)); + + // Register in the node dictionary so links can resolve this FI as a destination. + if (fiIndex >= 0) + node[fiIndex] = instance; + + error = null; + return true; + } + + // ── Runtime cloning ─────────────────────────────────────────────── + + /// + /// Maps each node in the template's + /// to the per-instance created for this run. + /// Populated by ; null between runs. + /// + private Dictionary? _runtimeModules; + + /// + /// Returns the per-instance cloned from + /// for this function instance, or null + /// if the instance has not yet been constructed for a run. + /// + internal IModule? GetRuntimeModule(Node templateNode) + => _runtimeModules is not null && _runtimeModules.TryGetValue(templateNode, out var m) ? m : null; + + /// + /// Instantiates a fresh for every node in the template's + /// internal boundary, keyed by the template's original . + /// Called by during a model-system run. + /// + internal bool ConstructRuntimeModules(XTMFRuntime runtime, ref string? error) + { + _runtimeModules = new Dictionary(ReferenceEqualityComparer.Instance); + var internals = Template.InternalModules; + foreach (var start in internals.Starts) + { + if (!start.ConstructModuleInstance(runtime, out var m, ref error)) return false; + _runtimeModules[start] = m!; + } + foreach (var node in internals.Modules) + { + if (!node.ConstructModuleInstance(runtime, out var m, ref error)) return false; + _runtimeModules[node] = m!; + } + error = null; + return true; + } + + /// + /// Wires the template's internal links using the per-instance cloned modules. + /// Called by during a model-system run. + /// + internal bool ConstructRuntimeLinks(ref string? error) + { + if (_runtimeModules is null) { error = null; return true; } + foreach (var link in Template.InternalModules.Links) + { + if (!ConstructRuntimeLink(link, ref error)) return false; + } + error = null; + return true; + } + + private IModule? ResolveRuntimeDestModule(Node dest) + { + var r = dest is GhostNode gn ? gn.ReferencedNode : dest; + return _runtimeModules!.TryGetValue(r, out var m) ? m : r.Module; + } + + private bool ConstructRuntimeLink(Link link, ref string? error) + { + if (!_runtimeModules!.TryGetValue(link.Origin, out var originModule)) + { + error = $"FunctionInstance '{Name}': internal link origin '{link.Origin.Name}' has no cloned module."; + return false; + } + if (link.IsDisabled) return true; + + if (link is SingleLink sl) + { + var dest = sl.Destination is GhostNode gn ? gn.ReferencedNode : sl.Destination!; + if (sl.OriginHook.Cardinality == HookCardinality.Single && dest.IsDisabled) + { + error = "An internal FunctionInstance link targets a disabled node for a required hook."; + return false; + } + var destModule = ResolveRuntimeDestModule(sl.Destination!); + if (destModule is not null) + sl.OriginHook.Install(originModule, destModule, 0); + } + else if (link is MultiLink ml) + { + int enabled = 0; + foreach (var d in ml.Destinations) + { + var r = d is GhostNode gn ? gn.ReferencedNode : d; + if (!r.IsDisabled && ResolveRuntimeDestModule(d) is not null) enabled++; + } + if (ml.OriginHook.Cardinality == HookCardinality.AtLeastOne && enabled == 0) + { + error = "An internal FunctionInstance MultiLink requires at least one enabled destination."; + return false; + } + ml.OriginHook.CreateArray(originModule, enabled); + int idx = 0; + foreach (var d in ml.Destinations) + { + var r = d is GhostNode gn ? gn.ReferencedNode : d; + var dm = ResolveRuntimeDestModule(d); + if (!r.IsDisabled && dm is not null) + ml.OriginHook.Install(originModule, dm, idx++); + } + } + return true; + } + + /// + /// Fills any unset AnyNumber hooks on the cloned modules with empty arrays. + /// Called by during a model-system run. + /// + internal void ConstructEmptyRuntimeLinks() + { + if (_runtimeModules is null) return; + foreach (var (node, module) in _runtimeModules) + { + foreach (var hook in node.Hooks) + { + if (hook.Cardinality == HookCardinality.AnyNumber && !hook.AnyInstalled(module)) + hook.CreateArray(module, 0); + } + } + } + + /// + /// Runs on all per-instance cloned modules. + /// Called by the run engine's runtime-validation phase. + /// + internal bool ValidateRuntimeModules(ref string? moduleName, ref string? error) + { + if (_runtimeModules is null) { error = null; return true; } + foreach (var (node, module) in _runtimeModules) + { + try + { + if (!module.RuntimeValidation(ref error)) + { + moduleName = Name + "." + node.Name; + return false; + } + } + catch (Exception e) + { + moduleName = Name + "." + node.Name; + error = e.Message; + return false; + } + } + return true; + } + } +} diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs index 70b3374..8fb88ea 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs @@ -18,12 +18,15 @@ You should have received a copy of the GNU General Public License */ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text; using System.Text.Json; using XTMF2.Editing; using XTMF2.Repository; +using XTMF2.RuntimeModules; namespace XTMF2.ModelSystemConstruct { @@ -34,6 +37,16 @@ namespace XTMF2.ModelSystemConstruct /// public sealed class FunctionTemplate : INotifyPropertyChanged { + // ── JSON property names ─────────────────────────────────────────── + private const string NameProperty = "Name"; + private const string LocationProperty = "Location"; + private const string ExposedNodesProperty = "ExposedNodes"; + private const string EntryNodeProperty = "EntryNode"; + private const string LocationXProperty = "X"; + private const string LocationYProperty = "Y"; + private const string LocationWProperty = "Width"; + private const string LocationHProperty = "Height"; + private string _name = String.Empty; /// @@ -49,11 +62,116 @@ public string Name } } + // ── Entry node ─────────────────────────────────────────────────── + private Node? _entryNode; + + /// + /// The node in that defines the runtime type of this + /// function template. Any node in InternalModules may serve as the entry node. + /// When set, the template's property reflects the entry node's type, + /// and objects whose hooks point to this template will + /// participate in the same type-compatibility checks as regular nodes. + /// + public Node? EntryNode => _entryNode; + + /// + /// The module type of the , or null when no entry + /// node has been assigned. This type is used by link compatibility checks so that + /// a hook expecting, e.g., IAction can connect to a + /// backed by this template. + /// + public Type? Type => _entryNode?.Type; + + /// + /// Designates as the entry node for this template. + /// Pass null to clear the entry-node assignment. + /// Called only by so that the change + /// participates in undo/redo. + /// + internal void SetEntryNode(Node? entryNode) + { + _entryNode = entryNode; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(EntryNode))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Type))); + } + + // ── Canvas location ─────────────────────────────────────────────── + private Rectangle _location = new Rectangle(40, 40, 200, 120); + + /// + /// The position and size of this function template's container box on the canvas. + /// + public Rectangle Location + { + get => _location; + } + + /// Sets the canvas location of the function template (called by the session). + internal void SetLocation(Rectangle location) + { + _location = location; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Location))); + } + + // ── Exposed nodes ───────────────────────────────────────────────── + /// + /// Nodes within that are exposed as external hooks + /// when the template is viewed from the parent boundary. + /// + private readonly ObservableCollection _exposedNodes = new(); + + /// + /// Read-only view of the nodes exposed as hooks on this function template's canvas box. + /// + public ReadOnlyObservableCollection ExposedNodes { get; } + + /// + /// Toggles the exposure of . + /// Adding it when not present; removing it when already exposed. + /// Node must belong to . + /// + internal bool ToggleExposedNode(Node node, [NotNullWhen(false)] out CommandError? error) + { + if (!InternalModules.Modules.Contains(node)) + { + error = new CommandError($"Node '{node.Name}' does not belong to the InternalModules of template '{Name}'."); + return false; + } + var nodeType = node.Type; + bool isBasicParameter = nodeType is { IsGenericType: true } + && nodeType.GetGenericTypeDefinition() == typeof(BasicParameter<>); + if (!isBasicParameter) + { + error = new CommandError($"Only BasicParameter nodes can be exposed as hooks. '{node.Name}' is not a BasicParameter."); + return false; + } + error = null; + if (_exposedNodes.Contains(node)) + _exposedNodes.Remove(node); + else + _exposedNodes.Add(node); + return true; + } + + /// + /// Forcibly removes from the exposed nodes list (used for undo). + /// + internal void RemoveExposedNode(Node node) => _exposedNodes.Remove(node); + + /// + /// Forcibly adds to the exposed nodes list (used for undo). + /// + internal void AddExposedNode(Node node) + { + if (!_exposedNodes.Contains(node)) + _exposedNodes.Add(node); + } + /// /// This boundary provides the location for modules that are contained within the function template. /// These modules can not be referenced from outside of the function template. /// - public Boundary InternalModules { get; } + public Boundary InternalModules { get; private set; } /// /// The boundary that this function template belongs to. @@ -65,26 +183,54 @@ public string Name /// /// The name of the function template. /// The boundary that owns this function template. - public FunctionTemplate(string name, Boundary parent) + /// An optional boundary to use as the InternalModules of this template; if null, an empty boundary will be created. + public FunctionTemplate(string name, Boundary parent, Boundary? internalModules = null) { _name = name; Parent = parent; - InternalModules = new Boundary("InternalModules", parent); + InternalModules = internalModules ?? new Boundary("InternalModules", parent); + ExposedNodes = new ReadOnlyObservableCollection(_exposedNodes); } /// /// Save the function template to the stream. /// /// A counting for module indexes. - /// An lookup given an index of contained nodes. + /// A lookup given an index of contained nodes. /// The known types and indexes for them. /// The stream that is being written to. internal void Save(ref int index, Dictionary nodeDictionary, Dictionary typeDictionary, Utf8JsonWriter writer) { writer.WriteStartObject(); - writer.WriteString("Name", Name); + writer.WriteString(NameProperty, Name); + + // Location + writer.WritePropertyName(LocationProperty); + writer.WriteStartObject(); + writer.WriteNumber(LocationXProperty, Location.X); + writer.WriteNumber(LocationYProperty, Location.Y); + writer.WriteNumber(LocationWProperty, Location.Width); + writer.WriteNumber(LocationHProperty, Location.Height); + writer.WriteEndObject(); + + // Internal modules (must come before ExposedNodes so that indices are defined) writer.WritePropertyName(nameof(InternalModules)); InternalModules.Save(ref index, nodeDictionary, typeDictionary, writer); + + // Exposed nodes – stored as integer indices + writer.WritePropertyName(ExposedNodesProperty); + writer.WriteStartArray(); + foreach (var en in _exposedNodes) + { + if (nodeDictionary.TryGetValue(en, out int idx)) + writer.WriteNumberValue(idx); + } + writer.WriteEndArray(); + + // Entry node – stored as an integer index (null means not set) + if (_entryNode != null && nodeDictionary.TryGetValue(_entryNode, out int entryIdx)) + writer.WriteNumber(EntryNodeProperty, entryIdx); + writer.WriteEndObject(); } @@ -112,7 +258,13 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo { template = null; string? name = null; + Rectangle location = new Rectangle(40, 40, 200, 120); var innerModules = new Boundary(parent); + // Exposed node indices – resolved after InternalModules is loaded. + var deferredExposedIndices = new List(); + // Entry node index – resolved after InternalModules is loaded. + int? deferredEntryNodeIndex = null; + if(reader.TokenType != JsonTokenType.StartObject) { return Helper.FailWith(out error, "Unexpected token when reading FunctionTemplate!"); @@ -123,11 +275,26 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo { continue; } - if(reader.ValueTextEquals(nameof(Name))) + if(reader.ValueTextEquals(NameProperty)) { reader.Read(); name = reader.GetString(); } + else if(reader.ValueTextEquals(LocationProperty)) + { + reader.Read(); // StartObject + float lx = 40, ly = 40, lw = 200, lh = 120; + while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if(reader.TokenType != JsonTokenType.PropertyName) continue; + if(reader.ValueTextEquals(LocationXProperty)) { reader.Read(); lx = reader.GetSingle(); } + else if(reader.ValueTextEquals(LocationYProperty)) { reader.Read(); ly = reader.GetSingle(); } + else if(reader.ValueTextEquals(LocationWProperty)) { reader.Read(); lw = reader.GetSingle(); } + else if(reader.ValueTextEquals(LocationHProperty)) { reader.Read(); lh = reader.GetSingle(); } + else reader.Skip(); + } + location = new Rectangle(lx, ly, lw, lh); + } else if(reader.ValueTextEquals(nameof(InternalModules))) { reader.Read(); @@ -136,12 +303,49 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo return false; } } + else if(reader.ValueTextEquals(ExposedNodesProperty)) + { + reader.Read(); // StartArray + while(reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if(reader.TokenType == JsonTokenType.Number) + deferredExposedIndices.Add(reader.GetInt32()); + } + } + else if(reader.ValueTextEquals(EntryNodeProperty)) + { + reader.Read(); + if(reader.TokenType == JsonTokenType.Number) + deferredEntryNodeIndex = reader.GetInt32(); + } + else + { + reader.Skip(); + } } if(name is null) { return Helper.FailWith(out error, "Function template did not include a name!"); } - template = new FunctionTemplate(name, parent); + template = new FunctionTemplate(name, parent, innerModules); + // Replace the empty InternalModules created by the constructor with the one + // loaded from disk (which already contains the correct nodes, starts, links, etc.). + template.SetLocation(location); + + // Resolve exposed node indices now that InternalModules nodes are in the dictionary. + foreach (int idx in deferredExposedIndices) + { + if (node.TryGetValue(idx, out var exposedNode)) + template._exposedNodes.Add(exposedNode); + } + + // Resolve the entry node index. + if (deferredEntryNodeIndex.HasValue + && node.TryGetValue(deferredEntryNodeIndex.Value, out var entryNodeCandidate)) + { + template._entryNode = entryNodeCandidate; + } + return true; } diff --git a/src/XTMF2/ModelSystemConstruct/GhostNode.cs b/src/XTMF2/ModelSystemConstruct/GhostNode.cs index acb7203..21a7efd 100644 --- a/src/XTMF2/ModelSystemConstruct/GhostNode.cs +++ b/src/XTMF2/ModelSystemConstruct/GhostNode.cs @@ -85,8 +85,12 @@ internal override void Save(ref int index, Dictionary nodeDictionary, /// internal void SaveObject(Dictionary nodeDictionary, Utf8JsonWriter writer) { + // Guard against a referenced node that was removed without cascade-deleting + // this ghost (should not happen in a healthy model, but avoids a hard crash). + if (!nodeDictionary.TryGetValue(ReferencedNode, out int refIdx)) + return; writer.WriteStartObject(); - writer.WriteNumber(ReferencedNodeProperty, nodeDictionary[ReferencedNode]); + writer.WriteNumber(ReferencedNodeProperty, refIdx); writer.WriteNumber(XProperty, Location.X); writer.WriteNumber(YProperty, Location.Y); writer.WriteNumber(WidthProperty, Location.Width); diff --git a/src/XTMF2/ModelSystemConstruct/MultiLink.cs b/src/XTMF2/ModelSystemConstruct/MultiLink.cs index 6a5a9ce..a6184a1 100644 --- a/src/XTMF2/ModelSystemConstruct/MultiLink.cs +++ b/src/XTMF2/ModelSystemConstruct/MultiLink.cs @@ -81,11 +81,23 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit internal override bool Construct(ref string? error) { - // Count enabled destinations, resolving ghost nodes to their real targets. + // Resolves a destination to its effective Node and per-instance IModule, + // handling both GhostNode cross-boundary references and FunctionInstance clones. + static (Node effectiveNode, IModule? destModule) ResolveDest(Node d) + { + var r = d is GhostNode gn ? gn.ReferencedNode : d; + if (r is FunctionInstance fi) + { + var entry = fi.Template.EntryNode; + return (entry ?? r, entry is not null ? fi.GetRuntimeModule(entry) : null); + } + return (r, r.Module); + } + var moduleCount = _Destinations.Count(d => { - var effective = d is GhostNode gn ? gn.ReferencedNode : d; - return !effective.IsDisabled; + var (node, _) = ResolveDest(d); + return !node.IsDisabled; }); if(OriginHook!.Cardinality == HookCardinality.AtLeastOne) { @@ -106,10 +118,10 @@ internal override bool Construct(ref string? error) int index = 0; for (int i = 0; i < _Destinations.Count; i++) { - var effectiveDest = _Destinations[i] is GhostNode gn ? gn.ReferencedNode : _Destinations[i]; - if (!effectiveDest.IsDisabled) + var (effectiveDest, destModule) = ResolveDest(_Destinations[i]); + if (!effectiveDest.IsDisabled && destModule is not null) { - OriginHook.Install(Origin!, effectiveDest, index++); + OriginHook.Install(Origin!.Module!, destModule, index++); } } } diff --git a/src/XTMF2/ModelSystemConstruct/Node.cs b/src/XTMF2/ModelSystemConstruct/Node.cs index 471cbf1..a0ff1f1 100644 --- a/src/XTMF2/ModelSystemConstruct/Node.cs +++ b/src/XTMF2/ModelSystemConstruct/Node.cs @@ -68,7 +68,7 @@ internal void UpdateContainedWithin(Boundary newBoundary) /// /// The type that this will represent /// - public Type Type => _type; + public virtual Type Type => _type; /// /// A parameter value to use if this is a parameter type @@ -107,6 +107,13 @@ private void CreateNodeHooks(ModuleRepository repository) public event PropertyChangedEventHandler? PropertyChanged; + /// + /// Raises for subclasses that cannot access the + /// backing delegate directly. + /// + protected void InvokePropertyChanged(string propertyName) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + /// /// Set the location of the node /// @@ -257,6 +264,29 @@ internal bool ConstructModule(XTMFRuntime runtime, ref string? error) return true; } + /// + /// Instantiates a fresh for this node's type without storing + /// it in . Used by FunctionInstance runtime cloning so that each + /// instance has its own independent module objects. + /// + internal bool ConstructModuleInstance(XTMFRuntime runtime, out IModule? module, ref string? error) + { + module = null; + if (_type is null) + return FailWith(out error, $"Unable to construct a module instance for '{Name}' without a type!"); + var typeInfo = _type.GetTypeInfo(); + if (( + typeInfo.GetConstructor(RuntimeConstructor)?.Invoke(new[] { runtime }) + ?? typeInfo.GetConstructor(EmptyConstructor)?.Invoke(EmptyConstructor)) is not IModule m) + return FailWith(out error, $"Unable to construct a module instance of type {_type.GetTypeInfo().AssemblyQualifiedName}!"); + module = m; + module.Name = Name; + if (ParameterValue is not null) + return ParameterValue.AssignToParameter(module, ref error); + error = null; + return true; + } + internal void ConstructEmptyLinks(ref string? error) { if (_type is null) diff --git a/src/XTMF2/ModelSystemConstruct/NodeHook.cs b/src/XTMF2/ModelSystemConstruct/NodeHook.cs index 61a1e56..102911d 100644 --- a/src/XTMF2/ModelSystemConstruct/NodeHook.cs +++ b/src/XTMF2/ModelSystemConstruct/NodeHook.cs @@ -80,6 +80,12 @@ protected static HookCardinality GetCardinality(Type type, bool required) /// internal abstract void Install(Node origin, Node destination, int index); + /// + /// Install pre-resolved module instances directly, bypassing . + /// Called when constructing per-instance FunctionInstance clones at runtime. + /// + internal abstract void Install(IModule origin, IModule destination, int index); + /// /// Create the array of data with the given size /// @@ -164,6 +170,24 @@ internal override void Install(Node origin, Node destination, int index) } } + internal override void Install(IModule origin, IModule destination, int index) + { + switch (Cardinality) + { + case HookCardinality.Single: + case HookCardinality.SingleOptional: + Property.SetValue(origin, destination); + break; + case HookCardinality.AnyNumber: + case HookCardinality.AtLeastOne: + if (Property.GetValue(origin) is Array data) + data.SetValue(destination, index); + break; + default: + throw new NotImplementedException("Unknown Cardinality!"); + } + } + internal override bool AnyInstalled(IModule module) { return Property.GetValue(module) is not null; @@ -230,6 +254,24 @@ internal override void Install(Node origin, Node destination, int index) } } + internal override void Install(IModule origin, IModule destination, int index) + { + switch (Cardinality) + { + case HookCardinality.Single: + case HookCardinality.SingleOptional: + Field.SetValue(origin, destination); + break; + case HookCardinality.AnyNumber: + case HookCardinality.AtLeastOne: + if (Field.GetValue(origin) is Array data) + data.SetValue(destination, index); + break; + default: + throw new NotImplementedException("Unknown Cardinality!"); + } + } + internal override bool AnyInstalled(IModule module) { return Field.GetValue(module) is not null; diff --git a/src/XTMF2/ModelSystemConstruct/SingleLink.cs b/src/XTMF2/ModelSystemConstruct/SingleLink.cs index 8aefb59..cc58225 100644 --- a/src/XTMF2/ModelSystemConstruct/SingleLink.cs +++ b/src/XTMF2/ModelSystemConstruct/SingleLink.cs @@ -58,8 +58,25 @@ 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!; + // Resolve ghost-node destinations to their real node. + var resolved = Destination is GhostNode gn ? gn.ReferencedNode : Destination!; + + // Determine the effective destination node (for cardinality/disabled checks) + // and the actual IModule to wire (per-instance clone for FunctionInstances). + Node effectiveDest; + IModule? destModule; + if (resolved is FunctionInstance fi) + { + effectiveDest = fi.Template.EntryNode ?? resolved; + destModule = fi.Template.EntryNode is not null + ? fi.GetRuntimeModule(fi.Template.EntryNode) + : null; + } + else + { + effectiveDest = resolved; + destModule = resolved.Module; + } // if not optional if (OriginHook!.Cardinality == HookCardinality.Single) @@ -75,10 +92,9 @@ internal override bool Construct(ref string? error) return false; } } - if (!IsDisabled) + if (!IsDisabled && destModule is not null) { - // The index doesn't matter for this type - OriginHook.Install(Origin!, effectiveDest, 0); + OriginHook.Install(Origin!.Module!, destModule, 0); } return true; } diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionInstances.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionInstances.cs new file mode 100644 index 0000000..eb4292f --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionInstances.cs @@ -0,0 +1,378 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; + +namespace XTMF2.UnitTests.Editing +{ + [TestClass] + public class TestFunctionInstances + { + // ── Helpers ──────────────────────────────────────────────────────────── + + /// + /// Creates a FunctionTemplate on the global boundary and returns it. + /// + private static FunctionTemplate AddTemplate(User user, ModelSystemSession mSession, + string templateName = "MyTemplate") + { + Assert.IsTrue(mSession.AddFunctionTemplate(user, mSession.ModelSystem.GlobalBoundary, + templateName, out var template, out var error), error?.Message); + return template!; + } + + /// + /// Creates a FunctionInstance on the global boundary of the given template. + /// + private static FunctionInstance AddInstance(User user, ModelSystemSession mSession, + FunctionTemplate template, string instanceName = "MyInstance", + Rectangle location = default) + { + if (location == default) + location = new Rectangle(10f, 20f, 160f, 70f); + + Assert.IsTrue(mSession.AddFunctionInstance(user, mSession.ModelSystem.GlobalBoundary, + template, instanceName, location, out var instance, out var error), error?.Message); + return instance!; + } + + // ── Add ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void TestAddFunctionInstance() + { + TestHelper.RunInModelSystemContext("TestAddFunctionInstance", (user, pSession, mSession) => + { + var template = AddTemplate(user, mSession); + var instances = mSession.ModelSystem.GlobalBoundary.FunctionInstances; + + Assert.IsEmpty(instances); + var instance = AddInstance(user, mSession, template); + + Assert.HasCount(1, instances); + Assert.AreEqual("MyInstance", instance.Name); + Assert.AreSame(template, instance.Template); + }); + } + + [TestMethod] + public void TestAddFunctionInstanceUndoRedo() + { + TestHelper.RunInModelSystemContext("TestAddFunctionInstanceUndoRedo", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instances = mSession.ModelSystem.GlobalBoundary.FunctionInstances; + + Assert.IsEmpty(instances); + AddInstance(user, mSession, template); + Assert.HasCount(1, instances); + + // Undo: instance should disappear + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.IsEmpty(instances); + + // Redo: instance returns + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.HasCount(1, instances); + }); + } + + [TestMethod] + public void TestAddMultipleFunctionInstances() + { + TestHelper.RunInModelSystemContext("TestAddMultipleFunctionInstances", (user, pSession, mSession) => + { + var template = AddTemplate(user, mSession); + var instances = mSession.ModelSystem.GlobalBoundary.FunctionInstances; + + AddInstance(user, mSession, template, "Instance1"); + AddInstance(user, mSession, template, "Instance2"); + AddInstance(user, mSession, template, "Instance3"); + + Assert.HasCount(3, instances); + Assert.AreEqual("Instance1", instances[0].Name); + Assert.AreEqual("Instance2", instances[1].Name); + Assert.AreEqual("Instance3", instances[2].Name); + }); + } + + [TestMethod] + public void TestAddFunctionInstancePreservesLocation() + { + TestHelper.RunInModelSystemContext("TestAddFunctionInstancePreservesLocation", (user, pSession, mSession) => + { + var template = AddTemplate(user, mSession); + var location = new Rectangle(50f, 75f, 200f, 100f); + var instance = AddInstance(user, mSession, template, location: location); + + Assert.AreEqual(50f, instance.Location.X); + Assert.AreEqual(75f, instance.Location.Y); + Assert.AreEqual(200f, instance.Location.Width); + Assert.AreEqual(100f, instance.Location.Height); + }); + } + + // ── Remove ───────────────────────────────────────────────────────────── + + [TestMethod] + public void TestRemoveFunctionInstance() + { + TestHelper.RunInModelSystemContext("TestRemoveFunctionInstance", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instances = mSession.ModelSystem.GlobalBoundary.FunctionInstances; + + var instance = AddInstance(user, mSession, template); + Assert.HasCount(1, instances); + + Assert.IsTrue(mSession.RemoveFunctionInstance(user, instance, out error), error?.Message); + Assert.IsEmpty(instances); + }); + } + + [TestMethod] + public void TestRemoveFunctionInstanceUndoRedo() + { + TestHelper.RunInModelSystemContext("TestRemoveFunctionInstanceUndoRedo", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instances = mSession.ModelSystem.GlobalBoundary.FunctionInstances; + + var instance = AddInstance(user, mSession, template); + Assert.HasCount(1, instances); + + Assert.IsTrue(mSession.RemoveFunctionInstance(user, instance, out error), error?.Message); + Assert.IsEmpty(instances); + + // Undo: instance returns + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.HasCount(1, instances); + + // Redo: instance removed again + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.IsEmpty(instances); + }); + } + + // ── Rename ───────────────────────────────────────────────────────────── + + [TestMethod] + public void TestRenameFunctionInstance() + { + TestHelper.RunInModelSystemContext("TestRenameFunctionInstance", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, "OriginalName"); + + Assert.AreEqual("OriginalName", instance.Name); + Assert.IsTrue(mSession.RenameFunctionInstance(user, instance, "RenamedInstance", out error), error?.Message); + Assert.AreEqual("RenamedInstance", instance.Name); + }); + } + + [TestMethod] + public void TestRenameFunctionInstanceUndoRedo() + { + TestHelper.RunInModelSystemContext("TestRenameFunctionInstanceUndoRedo", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, "OriginalName"); + + Assert.IsTrue(mSession.RenameFunctionInstance(user, instance, "NewName", out error), error?.Message); + Assert.AreEqual("NewName", instance.Name); + + // Undo: name reverts + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.AreEqual("OriginalName", instance.Name); + + // Redo: name returns to new value + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.AreEqual("NewName", instance.Name); + }); + } + + [TestMethod] + public void TestRenameFunctionInstanceRejectsEmptyName() + { + TestHelper.RunInModelSystemContext("TestRenameFunctionInstanceRejectsEmptyName", (user, pSession, mSession) => + { + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, "ValidName"); + + Assert.IsFalse(mSession.RenameFunctionInstance(user, instance, "", out _), + "Renaming with an empty string should fail."); + Assert.AreEqual("ValidName", instance.Name, "Name should be unchanged after a failed rename."); + }); + } + + [TestMethod] + public void TestRenameFunctionInstanceRejectsWhitespaceName() + { + TestHelper.RunInModelSystemContext("TestRenameFunctionInstanceRejectsWhitespaceName", (user, pSession, mSession) => + { + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, "ValidName"); + + Assert.IsFalse(mSession.RenameFunctionInstance(user, instance, " ", out _), + "Renaming with whitespace-only string should fail."); + Assert.AreEqual("ValidName", instance.Name, "Name should be unchanged after a failed rename."); + }); + } + + // ── SetLocation ──────────────────────────────────────────────────────── + + [TestMethod] + public void TestSetFunctionInstanceLocation() + { + TestHelper.RunInModelSystemContext("TestSetFunctionInstanceLocation", (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, location: new Rectangle(0f, 0f, 120f, 50f)); + + var newLocation = new Rectangle(300f, 400f, 220f, 110f); + Assert.IsTrue(mSession.SetFunctionInstanceLocation(user, instance, newLocation, out error), error?.Message); + + Assert.AreEqual(300f, instance.Location.X); + Assert.AreEqual(400f, instance.Location.Y); + Assert.AreEqual(220f, instance.Location.Width); + Assert.AreEqual(110f, instance.Location.Height); + }); + } + + [TestMethod] + public void TestSetFunctionInstanceLocationUndoRedo() + { + TestHelper.RunInModelSystemContext("TestSetFunctionInstanceLocationUndoRedo", (user, pSession, mSession) => + { + CommandError error = null; + var originalLocation = new Rectangle(10f, 20f, 160f, 70f); + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template, location: originalLocation); + + var newLocation = new Rectangle(500f, 600f, 240f, 120f); + Assert.IsTrue(mSession.SetFunctionInstanceLocation(user, instance, newLocation, out error), error?.Message); + Assert.AreEqual(500f, instance.Location.X); + + // Undo: location reverts + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.AreEqual(originalLocation.X, instance.Location.X); + Assert.AreEqual(originalLocation.Y, instance.Location.Y); + Assert.AreEqual(originalLocation.Width, instance.Location.Width); + Assert.AreEqual(originalLocation.Height, instance.Location.Height); + + // Redo: new location returns + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.AreEqual(newLocation.X, instance.Location.X); + Assert.AreEqual(newLocation.Y, instance.Location.Y); + Assert.AreEqual(newLocation.Width, instance.Location.Width); + Assert.AreEqual(newLocation.Height, instance.Location.Height); + }); + } + + // ── Save / Load ──────────────────────────────────────────────────────── + + [TestMethod] + public void TestFunctionInstanceSave() + { + TestHelper.RunInModelSystemContext("TestFunctionInstanceSave", + (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession, "SavedTemplate"); + AddInstance(user, mSession, template, "SavedInstance", + new Rectangle(11f, 22f, 160f, 70f)); + Assert.IsTrue(mSession.Save(out error), error?.Message); + }, + (user, pSession, mSession) => + { + var boundary = mSession.ModelSystem.GlobalBoundary; + Assert.HasCount(1, boundary.FunctionTemplates, + "FunctionTemplate was not reloaded."); + Assert.HasCount(1, boundary.FunctionInstances, + "FunctionInstance was not reloaded."); + + var fi = boundary.FunctionInstances[0]; + Assert.AreEqual("SavedInstance", fi.Name); + Assert.AreEqual("SavedTemplate", fi.Template.Name); + Assert.AreEqual(11f, fi.Location.X); + Assert.AreEqual(22f, fi.Location.Y); + }); + } + + [TestMethod] + public void TestFunctionInstanceSaveMultiple() + { + TestHelper.RunInModelSystemContext("TestFunctionInstanceSaveMultiple", + (user, pSession, mSession) => + { + CommandError error = null; + var template = AddTemplate(user, mSession, "MyTemplate"); + AddInstance(user, mSession, template, "Alpha", new Rectangle(10f, 10f, 120f, 50f)); + AddInstance(user, mSession, template, "Beta", new Rectangle(20f, 20f, 140f, 60f)); + Assert.IsTrue(mSession.Save(out error), error?.Message); + }, + (user, pSession, mSession) => + { + var boundary = mSession.ModelSystem.GlobalBoundary; + Assert.HasCount(2, boundary.FunctionInstances, + "Both FunctionInstances should be reloaded."); + Assert.AreEqual("Alpha", boundary.FunctionInstances[0].Name); + Assert.AreEqual("Beta", boundary.FunctionInstances[1].Name); + }); + } + + // ── Template reference integrity ────────────────────────────────────── + + [TestMethod] + public void TestFunctionInstanceReferencesCorrectTemplate() + { + TestHelper.RunInModelSystemContext("TestFunctionInstanceReferencesCorrectTemplate", (user, pSession, mSession) => + { + var templateA = AddTemplate(user, mSession, "TemplateA"); + var templateB = AddTemplate(user, mSession, "TemplateB"); + + var instanceA = AddInstance(user, mSession, templateA, "InstanceA"); + var instanceB = AddInstance(user, mSession, templateB, "InstanceB"); + + Assert.AreSame(templateA, instanceA.Template); + Assert.AreSame(templateB, instanceB.Template); + }); + } + + [TestMethod] + public void TestFunctionInstanceContainedWithin() + { + TestHelper.RunInModelSystemContext("TestFunctionInstanceContainedWithin", (user, pSession, mSession) => + { + var boundary = mSession.ModelSystem.GlobalBoundary; + var template = AddTemplate(user, mSession); + var instance = AddInstance(user, mSession, template); + + Assert.AreSame(boundary, instance.ContainedWithin); + }); + } + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateBoundaryIsolation.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateBoundaryIsolation.cs new file mode 100644 index 0000000..a7edea1 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateBoundaryIsolation.cs @@ -0,0 +1,214 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; +using XTMF2.UnitTests.Modules; + +namespace XTMF2.UnitTests.Editing +{ + /// + /// Verifies that nodes and ghost nodes cannot be moved across the boundary that + /// separates a FunctionTemplate's InternalModules scope from the global scope. + /// + [TestClass] + public class TestFunctionTemplateBoundaryIsolation + { + // ── Helpers ──────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "MyTemplate") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static Boundary AddChildBoundary(User user, ModelSystemSession ms, + Boundary parent, string name) + { + Assert.IsTrue(ms.AddBoundary(user, parent, name, out var b, out var err), err?.Message); + return b!; + } + + private static Node AddNode(User user, ModelSystemSession ms, Boundary boundary, + string name = "TestNode") + { + Assert.IsTrue(ms.AddNode(user, boundary, name, typeof(SimpleTestModule), + new Rectangle(10f, 10f, 120f, 50f), out var node, out var err), err?.Message); + return node!; + } + + // ── MoveNodeToBoundary ───────────────────────────────────────────────── + + [TestMethod] + public void TestMoveNode_FunctionTemplateInternal_ToGlobal_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveNode_FunctionTemplateInternal_ToGlobal_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var node = AddNode(user, ms, ft.InternalModules, "InternalNode"); + + var ok = ms.MoveNodeToBoundary(user, node, gb, out var error); + Assert.IsFalse(ok, "Expected move out of FunctionTemplate to be rejected."); + Assert.IsNotNull(error); + // Node must remain inside the FunctionTemplate. + Assert.AreSame(ft.InternalModules, node.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveNode_Global_ToFunctionTemplateInternal_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveNode_Global_ToFunctionTemplateInternal_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var node = AddNode(user, ms, gb, "GlobalNode"); + + var ok = ms.MoveNodeToBoundary(user, node, ft.InternalModules, out var error); + Assert.IsFalse(ok, "Expected move into FunctionTemplate from global scope to be rejected."); + Assert.IsNotNull(error); + Assert.AreSame(gb, node.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveNode_BetweenTwoFunctionTemplates_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveNode_BetweenTwoFunctionTemplates_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ftA = AddTemplate(user, ms, gb, "FtA"); + var ftB = AddTemplate(user, ms, gb, "FtB"); + var node = AddNode(user, ms, ftA.InternalModules, "InternalNode"); + + var ok = ms.MoveNodeToBoundary(user, node, ftB.InternalModules, out var error); + Assert.IsFalse(ok, "Expected move between two FunctionTemplates to be rejected."); + Assert.IsNotNull(error); + Assert.AreSame(ftA.InternalModules, node.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveNode_WithinFunctionTemplateChildBoundary_Accepted() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveNode_WithinFunctionTemplateChildBoundary_Accepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + // Add a child boundary inside the FunctionTemplate's InternalModules. + var internalChild = AddChildBoundary(user, ms, ft.InternalModules, "InternalChild"); + var node = AddNode(user, ms, ft.InternalModules, "InternalNode"); + + // Moving between InternalModules and its child must succeed (same FT scope). + var ok = ms.MoveNodeToBoundary(user, node, internalChild, out var error); + Assert.IsTrue(ok, error?.Message); + Assert.AreSame(internalChild, node.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveNode_Global_ToGlobalChildBoundary_Accepted() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveNode_Global_ToGlobalChildBoundary_Accepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var child = AddChildBoundary(user, ms, gb, "ChildA"); + var node = AddNode(user, ms, gb, "GlobalNode"); + + // Moving within the global scope (no FunctionTemplate involved) must still work. + var ok = ms.MoveNodeToBoundary(user, node, child, out var error); + Assert.IsTrue(ok, error?.Message); + Assert.AreSame(child, node.ContainedWithin); + }); + } + + // ── MoveGhostNodeToBoundary ──────────────────────────────────────────── + + [TestMethod] + public void TestMoveGhostNode_FunctionTemplateInternal_ToGlobal_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveGhostNode_FunctionTemplateInternal_ToGlobal_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + // The referenced real node lives on the global boundary. + var realNode = AddNode(user, ms, gb, "RealNode"); + var ft = AddTemplate(user, ms, gb); + + // Add a ghost node for realNode inside the FunctionTemplate's InternalModules. + Assert.IsTrue(ms.AddGhostNode(user, ft.InternalModules, realNode, + new Rectangle(10f, 10f, 120f, 50f), out var ghost, out var addErr), addErr?.Message); + + var ok = ms.MoveGhostNodeToBoundary(user, ghost!, gb, out var error); + Assert.IsFalse(ok, "Expected move of ghost node out of FunctionTemplate to be rejected."); + Assert.IsNotNull(error); + Assert.AreSame(ft.InternalModules, ghost!.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveGhostNode_Global_ToFunctionTemplateInternal_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveGhostNode_Global_ToFunctionTemplateInternal_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var realNode = AddNode(user, ms, gb, "RealNode"); + + // Ghost node on the global boundary. + Assert.IsTrue(ms.AddGhostNode(user, gb, realNode, + new Rectangle(10f, 10f, 120f, 50f), out var ghost, out var addErr), addErr?.Message); + + var ok = ms.MoveGhostNodeToBoundary(user, ghost!, ft.InternalModules, out var error); + Assert.IsFalse(ok, "Expected move of ghost node into FunctionTemplate to be rejected."); + Assert.IsNotNull(error); + Assert.AreSame(gb, ghost!.ContainedWithin); + }); + } + + [TestMethod] + public void TestMoveGhostNode_WithinFunctionTemplateChildBoundary_Accepted() + { + TestHelper.RunInModelSystemContext(nameof(TestMoveGhostNode_WithinFunctionTemplateChildBoundary_Accepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var internalChild = AddChildBoundary(user, ms, ft.InternalModules, "InternalChild"); + var realNode = AddNode(user, ms, ft.InternalModules, "RealNode"); + + Assert.IsTrue(ms.AddGhostNode(user, ft.InternalModules, realNode, + new Rectangle(10f, 10f, 120f, 50f), out var ghost, out var addErr), addErr?.Message); + + var ok = ms.MoveGhostNodeToBoundary(user, ghost!, internalChild, out var error); + Assert.IsTrue(ok, error?.Message); + Assert.AreSame(internalChild, ghost!.ContainedWithin); + }); + } + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs new file mode 100644 index 0000000..a63fa74 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs @@ -0,0 +1,261 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; +using XTMF2.UnitTests.Modules; + +namespace XTMF2.UnitTests.Editing; + +/// +/// Verifies the feature: +/// designation of an arbitrary node in +/// as the entry point, undo/redo support, save/load round-trip, and validation. +/// +[TestClass] +public class TestFunctionTemplateEntryNode +{ + // ── Helpers ──────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "MyTemplate") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static Node AddNodeTo(User user, ModelSystemSession ms, Boundary boundary, + string name = "InternalNode") + { + Assert.IsTrue(ms.AddNode(user, boundary, name, typeof(SimpleTestModule), + new Rectangle(10f, 10f, 120f, 50f), out var node, out var err), err?.Message); + return node!; + } + + // ── Basic set / clear ────────────────────────────────────────────────── + + [TestMethod] + public void TestSetEntryNode_ValidInternalNode_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_ValidInternalNode_Succeeds), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out var err), + err?.Message); + + Assert.AreSame(node, ft.EntryNode, "EntryNode should be the assigned node."); + }); + } + + [TestMethod] + public void TestSetEntryNode_NullClears_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_NullClears_Succeeds), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out _)); + Assert.IsNotNull(ft.EntryNode, "EntryNode should be set before clearing."); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, null, out var err), + err?.Message); + Assert.IsNull(ft.EntryNode, "EntryNode should be null after clearing."); + Assert.IsNull(ft.Type, "Type should be null when EntryNode is null."); + }); + } + + [TestMethod] + public void TestSetEntryNode_TypeMatchesNodeType() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_TypeMatchesNodeType), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out _)); + + Assert.AreEqual(node.Type, ft.Type, + "FunctionTemplate.Type should mirror the entry node's Type."); + }); + } + + // ── Validation ───────────────────────────────────────────────────────── + + [TestMethod] + public void TestSetEntryNode_NodeFromWrongBoundary_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_NodeFromWrongBoundary_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + // A node in the global boundary — NOT in InternalModules. + var outer = AddNodeTo(user, ms, gb, "OuterNode"); + + var ok = ms.SetFunctionTemplateEntryNode(user, ft, outer, out var err); + + Assert.IsFalse(ok, "Should reject a node that is not in InternalModules."); + Assert.IsNotNull(err, "An error should be returned."); + Assert.IsNull(ft.EntryNode, "EntryNode must remain unset after rejection."); + }); + } + + [TestMethod] + public void TestSetEntryNode_NodeFromDifferentTemplate_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_NodeFromDifferentTemplate_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft1 = AddTemplate(user, ms, gb, "TemplateA"); + var ft2 = AddTemplate(user, ms, gb, "TemplateB"); + var nodeInFt2 = AddNodeTo(user, ms, ft2.InternalModules, "NodeInB"); + + var ok = ms.SetFunctionTemplateEntryNode(user, ft1, nodeInFt2, out var err); + + Assert.IsFalse(ok, "Should reject a node from a different template's InternalModules."); + Assert.IsNotNull(err); + Assert.IsNull(ft1.EntryNode); + }); + } + + // ── Undo / Redo ──────────────────────────────────────────────────────── + + [TestMethod] + public void TestSetEntryNode_UndoRestoresPreviousValue() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_UndoRestoresPreviousValue), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var nodeA = AddNodeTo(user, ms, ft.InternalModules, "NodeA"); + var nodeB = AddNodeTo(user, ms, ft.InternalModules, "NodeB"); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, nodeA, out _)); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, nodeB, out _)); + Assert.AreSame(nodeB, ft.EntryNode, "EntryNode should be NodeB after second set."); + + Assert.IsTrue(ms.Undo(user, out var undoErr), undoErr?.Message); + Assert.AreSame(nodeA, ft.EntryNode, "Undo should restore NodeA."); + }); + } + + [TestMethod] + public void TestSetEntryNode_RedoReappliesChange() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_RedoReappliesChange), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out _)); + Assert.IsTrue(ms.Undo(user, out _)); + Assert.IsNull(ft.EntryNode, "EntryNode should be null after undo."); + + Assert.IsTrue(ms.Redo(user, out var redoErr), redoErr?.Message); + Assert.AreSame(node, ft.EntryNode, "Redo should reapply the entry node."); + }); + } + + [TestMethod] + public void TestSetEntryNode_UndoClear_RestoresNode() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_UndoClear_RestoresNode), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out _)); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, null, out _)); + Assert.IsNull(ft.EntryNode, "EntryNode should be null after clearing."); + + Assert.IsTrue(ms.Undo(user, out var err), err?.Message); + Assert.AreSame(node, ft.EntryNode, "Undo of clear should restore the node."); + }); + } + + // ── Save / Load ──────────────────────────────────────────────────────── + + [TestMethod] + public void TestSetEntryNode_SaveLoad_PreservesEntryNode() + { + TestHelper.RunInModelSystemContext("TestSetEntryNode_SaveLoad_PreservesEntryNode", + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var node = AddNodeTo(user, ms, ft.InternalModules); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, node, out _)); + Assert.IsTrue(ms.Save(out var err), err?.Message); + }, + (user, pSession, ms) => + { + var boundary = ms.ModelSystem.GlobalBoundary; + Assert.HasCount(1, boundary.FunctionTemplates, + "FunctionTemplate should be reloaded."); + var ft = boundary.FunctionTemplates[0]; + Assert.IsNotNull(ft.EntryNode, + "EntryNode should survive the save/load round-trip."); + Assert.AreEqual(typeof(SimpleTestModule), ft.Type, + "Type should be restored to the entry node's type."); + }); + } + + [TestMethod] + public void TestSetEntryNode_SaveLoad_NullEntryNode_RemainsNull() + { + TestHelper.RunInModelSystemContext("TestSetEntryNode_SaveLoad_NullEntryNode_RemainsNull", + (user, pSession, ms) => + { + // A template with no entry node is saved. + AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + Assert.IsTrue(ms.Save(out var err), err?.Message); + }, + (user, pSession, ms) => + { + var ft = ms.ModelSystem.GlobalBoundary.FunctionTemplates[0]; + Assert.IsNull(ft.EntryNode, + "EntryNode should remain null when none was set before saving."); + Assert.IsNull(ft.Type, + "Type should remain null when EntryNode is null."); + }); + } + + // ── Initial state ────────────────────────────────────────────────────── + + [TestMethod] + public void TestEntryNode_InitiallyNull() + { + TestHelper.RunInModelSystemContext(nameof(TestEntryNode_InitiallyNull), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + Assert.IsNull(ft.EntryNode, "A freshly created template should have no entry node."); + Assert.IsNull(ft.Type, "Type should be null when EntryNode is null."); + }); + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateNameUniqueness.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateNameUniqueness.cs new file mode 100644 index 0000000..61d0f40 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateNameUniqueness.cs @@ -0,0 +1,293 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; + +namespace XTMF2.UnitTests.Editing +{ + /// + /// Verifies that FunctionTemplate names are unique across the entire model-system boundary tree + /// so that FunctionInstances can never reference an ambiguous template. + /// + [TestClass] + public class TestFunctionTemplateNameUniqueness + { + // ── Helpers ──────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "MyTemplate") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static Boundary AddChildBoundary(User user, ModelSystemSession ms, + Boundary parent, string name) + { + Assert.IsTrue(ms.AddBoundary(user, parent, name, out var b, out var err), err?.Message); + return b!; + } + + // ── Add – same boundary ──────────────────────────────────────────────── + + [TestMethod] + public void TestAddFunctionTemplate_SameBoundary_DuplicateNameRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_SameBoundary_DuplicateNameRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + AddTemplate(user, ms, gb, "Alpha"); + + var ok = ms.AddFunctionTemplate(user, gb, "Alpha", out var ft2, out var error); + Assert.IsFalse(ok, "Expected duplicate to be rejected in the same boundary."); + Assert.IsNotNull(error); + }); + } + + [TestMethod] + public void TestAddFunctionTemplate_SameBoundary_UniqueNamesAccepted() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_SameBoundary_UniqueNamesAccepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + AddTemplate(user, ms, gb, "Alpha"); + AddTemplate(user, ms, gb, "Beta"); + Assert.HasCount(2, gb.FunctionTemplates); + }); + } + + // ── Add – parent / child boundary ───────────────────────────────────── + + [TestMethod] + public void TestAddFunctionTemplate_ChildBoundary_DuplicatesParentNameRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_ChildBoundary_DuplicatesParentNameRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var child = AddChildBoundary(user, ms, gb, "ChildA"); + AddTemplate(user, ms, gb, "Alpha"); + + var ok = ms.AddFunctionTemplate(user, child, "Alpha", out var ft2, out var error); + Assert.IsFalse(ok, "Expected duplicate of parent template to be rejected in child boundary."); + Assert.IsNotNull(error); + }); + } + + [TestMethod] + public void TestAddFunctionTemplate_ParentBoundary_DuplicatesChildNameRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_ParentBoundary_DuplicatesChildNameRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var child = AddChildBoundary(user, ms, gb, "ChildA"); + AddTemplate(user, ms, child, "Alpha"); + + var ok = ms.AddFunctionTemplate(user, gb, "Alpha", out var ft2, out var error); + Assert.IsFalse(ok, "Expected duplicate of child template to be rejected in parent boundary."); + Assert.IsNotNull(error); + }); + } + + [TestMethod] + public void TestAddFunctionTemplate_ChildBoundary_UniqueNameAccepted() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_ChildBoundary_UniqueNameAccepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var child = AddChildBoundary(user, ms, gb, "ChildA"); + AddTemplate(user, ms, gb, "Alpha"); + AddTemplate(user, ms, child, "Beta"); + Assert.HasCount(1, gb.FunctionTemplates); + Assert.HasCount(1, child.FunctionTemplates); + }); + } + + // ── Add – sibling boundaries ────────────────────────────────────────── + + [TestMethod] + public void TestAddFunctionTemplate_SiblingBoundary_DuplicateNameRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_SiblingBoundary_DuplicateNameRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var childA = AddChildBoundary(user, ms, gb, "ChildA"); + var childB = AddChildBoundary(user, ms, gb, "ChildB"); + AddTemplate(user, ms, childA, "Alpha"); + + var ok = ms.AddFunctionTemplate(user, childB, "Alpha", out var ft2, out var error); + Assert.IsFalse(ok, "Expected duplicate of sibling template to be rejected."); + Assert.IsNotNull(error); + }); + } + + [TestMethod] + public void TestAddFunctionTemplate_SiblingBoundary_UniqueNamesAccepted() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_SiblingBoundary_UniqueNamesAccepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var childA = AddChildBoundary(user, ms, gb, "ChildA"); + var childB = AddChildBoundary(user, ms, gb, "ChildB"); + AddTemplate(user, ms, childA, "Alpha"); + AddTemplate(user, ms, childB, "Beta"); + }); + } + + // ── Add – deeply nested ─────────────────────────────────────────────── + + [TestMethod] + public void TestAddFunctionTemplate_DeeplyNested_DuplicateRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_DeeplyNested_DuplicateRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var level1 = AddChildBoundary(user, ms, gb, "L1"); + var level2 = AddChildBoundary(user, ms, level1, "L2"); + AddTemplate(user, ms, level2, "DeepTemplate"); + + var ok = ms.AddFunctionTemplate(user, gb, "DeepTemplate", out var ft2, out var error); + Assert.IsFalse(ok, "Expected deeply nested duplicate to be rejected at root."); + Assert.IsNotNull(error); + + ok = ms.AddFunctionTemplate(user, level1, "DeepTemplate", out ft2, out error); + Assert.IsFalse(ok, "Expected deeply nested duplicate to be rejected at level1."); + Assert.IsNotNull(error); + }); + } + + // ── Rename ──────────────────────────────────────────────────────────── + + [TestMethod] + public void TestRenameFunctionTemplate_DuplicateNameSameBoundaryRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestRenameFunctionTemplate_DuplicateNameSameBoundaryRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var alpha = AddTemplate(user, ms, gb, "Alpha"); + AddTemplate(user, ms, gb, "Beta"); + + var ok = ms.RenameFunctionTemplate(user, alpha, "Beta", out var error); + Assert.IsFalse(ok, "Expected rename to existing name to be rejected."); + Assert.IsNotNull(error); + Assert.AreEqual("Alpha", alpha.Name); + }); + } + + [TestMethod] + public void TestRenameFunctionTemplate_DuplicateNameChildBoundaryRejected() + { + TestHelper.RunInModelSystemContext(nameof(TestRenameFunctionTemplate_DuplicateNameChildBoundaryRejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var child = AddChildBoundary(user, ms, gb, "ChildA"); + var alpha = AddTemplate(user, ms, gb, "Alpha"); + AddTemplate(user, ms, child, "Beta"); + + var ok = ms.RenameFunctionTemplate(user, alpha, "Beta", out var error); + Assert.IsFalse(ok, "Expected rename to a name in a child boundary to be rejected."); + Assert.AreEqual("Alpha", alpha.Name); + }); + } + + [TestMethod] + public void TestRenameFunctionTemplate_SameNameAccepted() + { + TestHelper.RunInModelSystemContext(nameof(TestRenameFunctionTemplate_SameNameAccepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var alpha = AddTemplate(user, ms, gb, "Alpha"); + + var ok = ms.RenameFunctionTemplate(user, alpha, "Alpha", out var error); + Assert.IsTrue(ok, error?.Message); + Assert.AreEqual("Alpha", alpha.Name); + }); + } + + [TestMethod] + public void TestRenameFunctionTemplate_UniqueNameAccepted() + { + TestHelper.RunInModelSystemContext(nameof(TestRenameFunctionTemplate_UniqueNameAccepted), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var alpha = AddTemplate(user, ms, gb, "Alpha"); + + var ok = ms.RenameFunctionTemplate(user, alpha, "Gamma", out var error); + Assert.IsTrue(ok, error?.Message); + Assert.AreEqual("Gamma", alpha.Name); + }); + } + + // ── Undo restores name availability ─────────────────────────────────── + + [TestMethod] + public void TestAddFunctionTemplate_AfterUndo_NameBecomesAvailableAgain() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionTemplate_AfterUndo_NameBecomesAvailableAgain), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + AddTemplate(user, ms, gb, "Alpha"); + + Assert.IsTrue(ms.Undo(user, out var undoErr), undoErr?.Message); + Assert.IsEmpty(gb.FunctionTemplates); + + AddTemplate(user, ms, gb, "Alpha"); + Assert.HasCount(1, gb.FunctionTemplates); + }); + } + + [TestMethod] + public void TestRenameFunctionTemplate_AfterUndo_OldNameFreedNewNameBlocked() + { + TestHelper.RunInModelSystemContext(nameof(TestRenameFunctionTemplate_AfterUndo_OldNameFreedNewNameBlocked), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var alpha = AddTemplate(user, ms, gb, "Alpha"); + + Assert.IsTrue(ms.RenameFunctionTemplate(user, alpha, "Gamma", out var err1), err1?.Message); + + // "Gamma" is now taken. + var ok = ms.AddFunctionTemplate(user, gb, "Gamma", out var ft2, out var error); + Assert.IsFalse(ok, "Expected 'Gamma' to be blocked after rename."); + + // Undo the rename — name reverts to "Alpha"; "Gamma" is free. + Assert.IsTrue(ms.Undo(user, out var err2), err2?.Message); + Assert.AreEqual("Alpha", alpha.Name); + + ok = ms.AddFunctionTemplate(user, gb, "Gamma", out ft2, out error); + Assert.IsTrue(ok, error?.Message); + }); + } + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs index 5835620..ca272e3 100644 --- a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs +++ b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs @@ -98,6 +98,52 @@ public void TestRemoveFunctionTemplateUndo() }); } + [TestMethod] + public void TestRemoveExposedNodeFromFunctionTemplate() + { + TestHelper.RunInModelSystemContext("TestRemoveExposedNodeFromFunctionTemplate", (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + Assert.IsTrue(mSession.AddFunctionTemplate(user, ms.GlobalBoundary, "MyFT", out FunctionTemplate template, out error), error?.Message); + Assert.IsTrue(mSession.AddNode(user, template.InternalModules, "MyParam", + typeof(XTMF2.RuntimeModules.BasicParameter), Rectangle.Hidden, + out var node, out error), error?.Message); + Assert.IsTrue(mSession.ToggleFunctionTemplateExposedNode(user, template, node, out error), error?.Message); + Assert.HasCount(1, template.ExposedNodes, "Node should be exposed before deletion."); + + Assert.IsTrue(mSession.RemoveNode(user, node, out error), error?.Message); + Assert.IsEmpty(template.ExposedNodes, "Deleting an exposed node must also remove it from ExposedNodes."); + }); + } + + [TestMethod] + public void TestRemoveExposedNodeFromFunctionTemplateUndoRedo() + { + TestHelper.RunInModelSystemContext("TestRemoveExposedNodeFromFunctionTemplateUndoRedo", (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + Assert.IsTrue(mSession.AddFunctionTemplate(user, ms.GlobalBoundary, "MyFT", out FunctionTemplate template, out error), error?.Message); + Assert.IsTrue(mSession.AddNode(user, template.InternalModules, "MyParam", + typeof(XTMF2.RuntimeModules.BasicParameter), Rectangle.Hidden, + out var node, out error), error?.Message); + Assert.IsTrue(mSession.ToggleFunctionTemplateExposedNode(user, template, node, out error), error?.Message); + Assert.HasCount(1, template.ExposedNodes, "Node should be exposed before deletion."); + + Assert.IsTrue(mSession.RemoveNode(user, node, out error), error?.Message); + Assert.IsEmpty(template.ExposedNodes, "Deleting an exposed node must also remove it from ExposedNodes."); + + // Undo: node comes back and should be re-exposed. + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.HasCount(1, template.ExposedNodes, "Undo should restore the node to ExposedNodes."); + + // Redo: node is deleted again, exposed list should be empty again. + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.IsEmpty(template.ExposedNodes, "Redo should re-remove the node from ExposedNodes."); + }); + } + [TestMethod] public void TestFunctionTemplateSave() { diff --git a/tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs b/tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs new file mode 100644 index 0000000..3394bb6 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs @@ -0,0 +1,193 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; +using XTMF2.UnitTests.Modules; + +namespace XTMF2.UnitTests.Editing +{ + /// + /// Regression tests for ghost-node operations scoped to a + /// 's InternalModules boundary. + /// + [TestClass] + public class TestGhostNodeInFunctionTemplate + { + // ── Helpers ──────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "MyTemplate") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static Node AddNode(User user, ModelSystemSession ms, Boundary boundary, + string name = "TestNode") + { + Assert.IsTrue(ms.AddNode(user, boundary, name, typeof(SimpleTestModule), + new Rectangle(10f, 10f, 120f, 50f), out var node, out var err), err?.Message); + return node!; + } + + private static GhostNode AddGhost(User user, ModelSystemSession ms, + Boundary boundary, Node realNode) + { + Assert.IsTrue(ms.AddGhostNode(user, boundary, realNode, + new Rectangle(200f, 10f, 120f, 50f), out var ghost, out var err), err?.Message); + return ghost!; + } + + // ── RemoveGhostNode inside InternalModules ───────────────────────────── + + [TestMethod] + public void TestRemoveGhostNode_InsideFunctionTemplate_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveGhostNode_InsideFunctionTemplate_Succeeds), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var realNode = AddNode(user, ms, gb, "RealNode"); + + // Place a ghost inside the template. + var ghost = AddGhost(user, ms, ft.InternalModules, realNode); + Assert.HasCount(1, ft.InternalModules.GhostNodes); + + // The deletion should succeed. + Assert.IsTrue(ms.RemoveGhostNode(user, ghost, out var err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes, + "Ghost node should have been removed from InternalModules."); + }); + } + + [TestMethod] + public void TestRemoveGhostNode_InsideFunctionTemplate_UndoRedo() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveGhostNode_InsideFunctionTemplate_UndoRedo), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var realNode = AddNode(user, ms, gb, "RealNode"); + var ghost = AddGhost(user, ms, ft.InternalModules, realNode); + + Assert.IsTrue(ms.RemoveGhostNode(user, ghost, out var err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes); + + // Undo should restore the ghost. + Assert.IsTrue(ms.Undo(user, out err), err?.Message); + Assert.HasCount(1, ft.InternalModules.GhostNodes); + + // Redo should remove it again. + Assert.IsTrue(ms.Redo(user, out err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes); + }); + } + + // ── Cascade deletion: removing the real node must also remove ghosts + // that live inside a FunctionTemplate's InternalModules. ───────────── + + [TestMethod] + public void TestRemoveRealNode_CascadesGhostInsideFunctionTemplate() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveRealNode_CascadesGhostInsideFunctionTemplate), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var realNode = AddNode(user, ms, gb, "RealNode"); + + // Ghost lives inside the FunctionTemplate. + _ = AddGhost(user, ms, ft.InternalModules, realNode); + Assert.HasCount(1, ft.InternalModules.GhostNodes); + + // Removing the real node should cascade-delete the ghost. + Assert.IsTrue(ms.RemoveNode(user, realNode, out var err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes, + "Ghost inside InternalModules should be cascade-deleted when its real node is removed."); + }); + } + + [TestMethod] + public void TestRemoveRealNode_CascadesGhostInsideFunctionTemplate_UndoRedo() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveRealNode_CascadesGhostInsideFunctionTemplate_UndoRedo), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var realNode = AddNode(user, ms, gb, "RealNode"); + _ = AddGhost(user, ms, ft.InternalModules, realNode); + + Assert.IsTrue(ms.RemoveNode(user, realNode, out var err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes); + + // Undo: the real node comes back, and so should its ghost. + Assert.IsTrue(ms.Undo(user, out err), err?.Message); + Assert.HasCount(1, ft.InternalModules.GhostNodes, + "Ghost inside InternalModules should be restored on undo."); + Assert.IsTrue(gb.Modules.Contains(realNode), + "Real node should be restored on undo."); + + // Redo: both are removed again. + Assert.IsTrue(ms.Redo(user, out err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes); + }); + } + + // ── Link cleanup: links going to a ghost inside InternalModules ──────── + + [TestMethod] + public void TestRemoveGhostNode_InsideFunctionTemplate_IncomingLinkCleaned() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveGhostNode_InsideFunctionTemplate_IncomingLinkCleaned), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + + // Origin: SimpleParameterModule (has a Parameter hook for IFunction). + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "Origin", + typeof(SimpleParameterModule), new Rectangle(10f, 10f, 120f, 50f), + out var origin, out var nodeErr), nodeErr?.Message); + + // Real node the ghost will reference (lives in InternalModules too). + var realNode = AddNode(user, ms, ft.InternalModules, "RealNode"); + var ghost = AddGhost(user, ms, ft.InternalModules, realNode); + + // Link origin → ghost using the first available hook. + var hooks = origin!.Hooks; + Assert.IsTrue(hooks.Count > 0, "SimpleParameterModule must expose at least one hook."); + Assert.IsTrue(ms.AddLink(user, origin, hooks[0], ghost, out _, out var linkErr), + linkErr?.Message); + + Assert.HasCount(1, ft.InternalModules.Links); + + // Delete the ghost; the link should be cleaned up. + Assert.IsTrue(ms.RemoveGhostNode(user, ghost, out var err), err?.Message); + Assert.IsEmpty(ft.InternalModules.GhostNodes); + Assert.IsEmpty(ft.InternalModules.Links, + "Links pointing to a deleted ghost inside InternalModules should be removed."); + }); + } + } +} From ff554a6326a918a45877f7e92cc558223f106dfa Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 2 Apr 2026 22:11:43 -0400 Subject: [PATCH 02/41] Stop deleting a FunctionTemplate that has instances --- src/XTMF2/Editing/ModelSystemSession.cs | 36 +++++++++++++ .../XTMF2.UnitTests/Editing/TestFunctions.cs | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 48016e6..8a2920c 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -1491,6 +1491,29 @@ private static void RestoreIncomingLinks( /// Returns every anywhere in the model system that /// references . /// + /// + /// Returns every anywhere in the model system that + /// references . + /// + private List GetAllFunctionInstancesOf(FunctionTemplate template) + { + 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 ft in current.FunctionTemplates) + stack.Push(ft.InternalModules); + foreach (var fi in current.FunctionInstances) + if (fi.Template == template) + result.Add(fi); + } + return result; + } + private List GetAllGhostNodesOf(Node realNode) { var result = new List(); @@ -1978,6 +2001,19 @@ public bool RemoveFunctionTemplate(User user, Boundary boundary, FunctionTemplat error = new CommandError("The user does not have access to this project.", true); return false; } + + // Refuse the removal if at least one FunctionInstance still references this template. + var referencing = GetAllFunctionInstancesOf(functionTemplate); + if (referencing.Count > 0) + { + var names = string.Join(", ", referencing.Select(fi => $"'{fi.Name}'")); + error = new CommandError( + $"Cannot remove FunctionTemplate '{functionTemplate.Name}' because it is still " + + $"referenced by the following function instance(s): {names}. " + + $"Remove those instances first."); + return false; + } + if (!boundary.RemoveFunctionTemplate(functionTemplate, out error)) { error = new CommandError($"Failed to remove function template {functionTemplate.Name} from boundary {boundary.Name}: {error?.Message}"); diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs index ca272e3..1dd1292 100644 --- a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs +++ b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs @@ -144,6 +144,57 @@ public void TestRemoveExposedNodeFromFunctionTemplateUndoRedo() }); } + [TestMethod] + public void TestRemoveFunctionTemplate_WithReferencingInstance_Fails() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveFunctionTemplate_WithReferencingInstance_Fails), + (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + var gb = ms.GlobalBoundary; + + Assert.IsTrue(mSession.AddFunctionTemplate(user, gb, "MyTemplate", + out var template, out error), error?.Message); + + // Place an instance that references the template. + Assert.IsTrue(mSession.AddFunctionInstance(user, gb, template, "MyInstance", + new Rectangle(10f, 10f, 160f, 70f), out _, out error), error?.Message); + + // Removing the template while the instance still exists must be rejected. + var ok = mSession.RemoveFunctionTemplate(user, gb, template, out error); + Assert.IsFalse(ok, + "RemoveFunctionTemplate should fail when a FunctionInstance still references it."); + Assert.IsNotNull(error); + Assert.HasCount(1, gb.FunctionTemplates, + "The template must still be present after the failed removal."); + }); + } + + [TestMethod] + public void TestRemoveFunctionTemplate_AfterInstanceRemoved_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveFunctionTemplate_AfterInstanceRemoved_Succeeds), + (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + var gb = ms.GlobalBoundary; + + Assert.IsTrue(mSession.AddFunctionTemplate(user, gb, "MyTemplate", + out var template, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionInstance(user, gb, template, "MyInstance", + new Rectangle(10f, 10f, 160f, 70f), out var instance, out error), error?.Message); + + // Remove the instance first… + Assert.IsTrue(mSession.RemoveFunctionInstance(user, instance, out error), error?.Message); + + // …now the template removal should succeed. + Assert.IsTrue(mSession.RemoveFunctionTemplate(user, gb, template, out error), error?.Message); + Assert.IsEmpty(gb.FunctionTemplates); + }); + } + [TestMethod] public void TestFunctionTemplateSave() { From 64e57949a459919b4b20a2ff3faf913cca758e12 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 2 Apr 2026 22:32:09 -0400 Subject: [PATCH 03/41] Fixed rendering issue when undoing an EntryNode change --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 35 +++++- .../ViewModels/ModelSystemEditorViewModel.cs | 9 ++ src/XTMF2/Editing/ModelSystemSession.cs | 31 +++++ .../Editing/TestFunctionTemplateEntryNode.cs | 106 ++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 267b305..73ec05f 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -185,7 +185,10 @@ public sealed class ModelSystemCanvas : Control private static readonly Typeface DefaultTypeface = new Typeface("Segoe UI, Arial, sans-serif"); // ── ViewModel ───────────────────────────────────────────────────────── - private ModelSystemEditorViewModel? _vm; + private ModelSystemEditorViewModel? _vm; + /// Tracks the FunctionTemplate we are currently subscribed to for PropertyChanged, + /// so we can unsubscribe when navigating away. + private FunctionTemplateViewModel? _subscribedCurrentFunctionTemplate; // ── Per-frame hook anchor cache (rebuilt in BuildHookAnchorCache) ───── private readonly Dictionary<(NodeViewModel, NodeHook), Point> @@ -505,11 +508,13 @@ private void Attach() foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged += OnElementPropertyChanged; foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged += OnElementPropertyChanged; foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged += OnElementPropertyChanged; + _vm.RenderRequested += OnRenderRequested; } private void Detach() { if (_vm is null) return; + _vm.RenderRequested -= OnRenderRequested; _vm.Nodes.CollectionChanged -= OnCollectionChanged; _vm.Starts.CollectionChanged -= OnCollectionChanged; _vm.Links.CollectionChanged -= OnCollectionChanged; @@ -526,6 +531,12 @@ private void Detach() foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged -= OnElementPropertyChanged; foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged -= OnElementPropertyChanged; foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged -= OnElementPropertyChanged; + + if (_subscribedCurrentFunctionTemplate is not null) + { + ((INotifyPropertyChanged)_subscribedCurrentFunctionTemplate).PropertyChanged -= OnElementPropertyChanged; + _subscribedCurrentFunctionTemplate = null; + } } private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -545,12 +556,34 @@ private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateVisual); } + private void OnRenderRequested(object? sender, EventArgs e) + => Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateVisual); + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(ModelSystemEditorViewModel.SelectedElement) or nameof(ModelSystemEditorViewModel.SelectedLink) or nameof(ModelSystemEditorViewModel.ShowAllHooks)) Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateAndMeasure); + + + // When we navigate into or out of a FunctionTemplate, maintain a direct subscription + // to the template VM so that property changes (e.g. EntryNode after undo) still + // trigger InvalidateVisual even though the VM is no longer in FunctionTemplates. + if (e.PropertyName is nameof(ModelSystemEditorViewModel.IsInsideFunctionTemplate) && _vm is not null) + { + if (_subscribedCurrentFunctionTemplate is not null) + { + ((INotifyPropertyChanged)_subscribedCurrentFunctionTemplate).PropertyChanged -= OnElementPropertyChanged; + _subscribedCurrentFunctionTemplate = null; + } + if (_vm.CurrentFunctionTemplate is { } current) + { + _subscribedCurrentFunctionTemplate = current; + ((INotifyPropertyChanged)current).PropertyChanged += OnElementPropertyChanged; + } + Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateVisual); + } } private void InvalidateAndMeasure() diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index e8e5787..0fdf7be 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -2104,12 +2104,20 @@ private void SetLinkGroupSelected(Link underlyingLink, bool selected) // ── Undo / Redo commands ────────────────────────────────────────────── + /// + /// Raised after every undo or redo attempt (successful or not) so that the canvas + /// can invalidate itself; this is necessary because the model change may not produce + /// any observable-property notification that the canvas already listens to. + /// + internal event EventHandler? RenderRequested; + /// Undo the last command in the session buffer. [RelayCommand] private async Task Undo() { if (!Session.Undo(User, out var error)) await ShowError("Undo Failed", error!); + RenderRequested?.Invoke(this, EventArgs.Empty); } /// Redo the previously undone command. @@ -2118,6 +2126,7 @@ private async Task Redo() { if (!Session.Redo(User, out var error)) await ShowError("Redo Failed", error!); + RenderRequested?.Invoke(this, EventArgs.Empty); } /// diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 8a2920c..5824b54 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -2148,6 +2148,37 @@ public bool SetFunctionTemplateEntryNode(User user, FunctionTemplate template, N $"The node '{entryNode.Name}' does not belong to the InternalModules of template '{template.Name}'."); return false; } + + // Reject the change when it would break at least one existing link whose + // destination is a FunctionInstance of this template. + // FunctionInstance.Type returns entryNode.Type (or typeof(object) when null). + var newType = entryNode?.Type; + var instances = GetAllFunctionInstancesOf(template); + foreach (var fi in instances) + { + var incomingLinks = GetLinksGoingTo(fi); + foreach (var link in incomingLinks) + { + var hookType = link.OriginHook!.Type; + // For array hooks, check the element type. + var effectiveHookType = hookType.IsArray + ? hookType.GetElementType()! + : hookType; + // A null newType maps to typeof(object), which satisfies no typed hook. + bool compatible = newType is not null + && effectiveHookType.IsAssignableFrom(newType); + if (!compatible) + { + error = new CommandError( + $"Cannot change the entry node of template '{template.Name}': " + + $"FunctionInstance '{fi.Name}' is wired to hook '{link.OriginHook.Name}' " + + $"(type '{effectiveHookType.Name}') on node '{link.Origin?.Name}', " + + $"which is incompatible with the new entry-node type '{newType?.Name ?? "none"}'."); + return false; + } + } + } + var oldEntryNode = template.EntryNode; template.SetEntryNode(entryNode); Buffer.AddUndo(new Command(() => diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs index a63fa74..068cf29 100644 --- a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs @@ -245,6 +245,112 @@ public void TestSetEntryNode_SaveLoad_NullEntryNode_RemainsNull() }); } + // ── Compatibility check with linked FunctionInstances ────────────────── + + [TestMethod] + public void TestSetEntryNode_WithLinkedInstance_IncompatibleNewType_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_WithLinkedInstance_IncompatibleNewType_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + + // Entry node is SimpleTestModule : IFunction + var entryNode = AddNodeTo(user, ms, ft.InternalModules, "Entry"); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, entryNode, out _)); + + // FunctionInstance on the global boundary. + Assert.IsTrue(ms.AddFunctionInstance(user, gb, ft, "FI", + new Rectangle(200f, 10f, 160f, 70f), out var fi, out var fiErr), fiErr?.Message); + + // Wire a SimpleParameterModule (hook[0]: IFunction) → FunctionInstance. + Assert.IsTrue(ms.AddNode(user, gb, "Origin", typeof(SimpleParameterModule), + new Rectangle(10f, 10f, 120f, 50f), out var origin, out var nodeErr), nodeErr?.Message); + Assert.IsTrue(ms.AddLink(user, origin!, origin!.Hooks[0], fi!, out _, out var linkErr), + linkErr?.Message); + + // Add a BasicParameter (IFunction) inside InternalModules as the + // "new" entry node — its type is not assignable to IFunction. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "IntParam", + typeof(XTMF2.RuntimeModules.BasicParameter), + new Rectangle(10f, 80f, 120f, 50f), out var incompatible, out var n2Err), n2Err?.Message); + + var ok = ms.SetFunctionTemplateEntryNode(user, ft, incompatible, out var err); + Assert.IsFalse(ok, + "Changing the entry node to an incompatible type should be rejected."); + Assert.IsNotNull(err, "An error message must be provided."); + // Entry node must be unchanged. + Assert.AreSame(entryNode, ft.EntryNode, + "EntryNode must remain the original node after rejection."); + }); + } + + [TestMethod] + public void TestSetEntryNode_WithLinkedInstance_CompatibleNewType_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_WithLinkedInstance_CompatibleNewType_Succeeds), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + + // Entry node: SimpleTestModule : IFunction + var entryNode = AddNodeTo(user, ms, ft.InternalModules, "Entry"); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, entryNode, out _)); + + Assert.IsTrue(ms.AddFunctionInstance(user, gb, ft, "FI", + new Rectangle(200f, 10f, 160f, 70f), out var fi, out var fiErr), fiErr?.Message); + + Assert.IsTrue(ms.AddNode(user, gb, "Origin", typeof(SimpleParameterModule), + new Rectangle(10f, 10f, 120f, 50f), out var origin, out var nodeErr), nodeErr?.Message); + Assert.IsTrue(ms.AddLink(user, origin!, origin!.Hooks[0], fi!, out _, out var linkErr), + linkErr?.Message); + + // BasicParameter also implements IFunction — compatible. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "StrParam", + typeof(XTMF2.RuntimeModules.BasicParameter), + new Rectangle(10f, 80f, 120f, 50f), out var compatible, out var n2Err), n2Err?.Message); + + var ok = ms.SetFunctionTemplateEntryNode(user, ft, compatible, out var err); + Assert.IsTrue(ok, err?.Message); + Assert.AreSame(compatible, ft.EntryNode, + "EntryNode should be updated to the compatible node."); + }); + } + + [TestMethod] + public void TestSetEntryNode_ClearWithLinkedInstance_Rejected() + { + TestHelper.RunInModelSystemContext(nameof(TestSetEntryNode_ClearWithLinkedInstance_Rejected), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + + var entryNode = AddNodeTo(user, ms, ft.InternalModules, "Entry"); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, entryNode, out _)); + + Assert.IsTrue(ms.AddFunctionInstance(user, gb, ft, "FI", + new Rectangle(200f, 10f, 160f, 70f), out var fi, out var fiErr), fiErr?.Message); + + Assert.IsTrue(ms.AddNode(user, gb, "Origin", typeof(SimpleParameterModule), + new Rectangle(10f, 10f, 120f, 50f), out var origin, out var nodeErr), nodeErr?.Message); + Assert.IsTrue(ms.AddLink(user, origin!, origin!.Hooks[0], fi!, out _, out var linkErr), + linkErr?.Message); + + // Clearing the entry node sets effective type to typeof(object), + // which is not assignable to IFunction. + var ok = ms.SetFunctionTemplateEntryNode(user, ft, null, out var err); + Assert.IsFalse(ok, + "Clearing the entry node should be rejected when a linked FunctionInstance " + + "is wired to a typed hook that typeof(object) does not satisfy."); + Assert.IsNotNull(err); + Assert.AreSame(entryNode, ft.EntryNode, + "EntryNode must remain set after the rejected clear."); + }); + } + // ── Initial state ────────────────────────────────────────────────────── [TestMethod] From 1179945fb1d0980493a1dd1d2d7da39f5a949dc1 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 2 Apr 2026 22:43:48 -0400 Subject: [PATCH 04/41] Add cascade deleting nodes that are acting as parameters. --- src/XTMF2/Editing/ModelSystemSession.cs | 68 ++++++++++++++++++++++- tests/XTMF2.UnitTests/Editing/TestNode.cs | 64 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 5824b54..1247239 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -1102,6 +1102,35 @@ public bool RemoveNode(User user, Node node, [NotNullWhen(false)] out CommandErr }) .ToList(); + // Collect hidden (embedded) nodes: destination nodes of outgoing links that + // reside in the same boundary and carry a Rectangle.Hidden location. + // These are visually embedded within the owning node and must be deleted with it. + var hiddenNodes = outgoingLinks + .SelectMany(l => + l is SingleLink sl && sl.Destination is not null ? new[] { sl.Destination } + : l is MultiLink ml ? ml.Destinations.ToArray() + : Array.Empty()) + .Where(n => n.Location.Equals(Rectangle.Hidden) && ReferenceEquals(n.ContainedWithin, boundary)) + .Distinct() + .ToList(); + + // For each hidden node, capture any OTHER incoming links (not the owner→hidden + // links already in outgoingLinks) plus its ghost cascade, for clean undo/redo. + var hiddenCascadeData = hiddenNodes + .Select(hn => + { + var hnLinks = GetLinksGoingTo(hn).Where(l => !outgoingLinks.Contains(l)).ToList(); + var hnMulti = BuildMultiLinkRestoreInfo(hnLinks, hn); + var hnGhosts = GetAllGhostNodesOf(hn); + var hnGhostData = hnGhosts.Select(g => + { + var gLinks = GetLinksGoingTo(g); + return (Ghost: g, Links: gLinks, MultiInfo: BuildMultiLinkRestoreInfo(gLinks, g)); + }).ToList(); + return (Node: hn, OtherIncoming: hnLinks, MultiInfo: hnMulti, GhostData: hnGhostData); + }) + .ToList(); + // Remove all incoming links (or just the relevant destination entries). void RemoveIncoming() { @@ -1132,11 +1161,41 @@ void RestoreGhostCascade() } } - // Remove incoming links, then ghost cascade, then outgoing links, then the node itself. + void RemoveHiddenCascade() + { + foreach (var (hn, hnLinks, hnMulti, hnGhostData) in hiddenCascadeData) + { + foreach (var (ghost, gLinks, gMulti) in hnGhostData) + { + RemoveIncomingLinks(gLinks, ghost, gMulti); + ghost.ContainedWithin!.RemoveGhostNode(ghost, out _); + } + RemoveIncomingLinks(hnLinks, hn, hnMulti); + boundary.RemoveNode(hn, out _); + } + } + + void RestoreHiddenCascade() + { + foreach (var (hn, hnLinks, hnMulti, hnGhostData) in hiddenCascadeData) + { + boundary.AddNode(hn, out _); + RestoreIncomingLinks(hnLinks, hn, hnMulti); + foreach (var (ghost, gLinks, gMulti) in hnGhostData) + { + ghost.ContainedWithin!.AddGhostNode(ghost, out _); + RestoreIncomingLinks(gLinks, ghost, gMulti); + } + } + } + + // Remove incoming links, then ghost cascade, then outgoing links, + // then the hidden embedded nodes, then the node itself. RemoveIncoming(); RemoveGhostCascade(); foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); + RemoveHiddenCascade(); // Also remove from model system variables if present, capturing position for undo. var variableIndex = ModelSystem.Variables.IndexOf(node); @@ -1155,9 +1214,10 @@ void RestoreGhostCascade() { Buffer.AddUndo(new Command(() => { - // Undo: restore node first, then its outgoing links, then all incoming links, then ghosts. + // Undo: restore node first, then hidden nodes, then outgoing links, then incoming links + ghosts. if (boundary.AddNode(node, out var e)) { + RestoreHiddenCascade(); foreach (var link in outgoingLinks) boundary.AddLink(link, out e); RestoreIncoming(); @@ -1179,6 +1239,7 @@ void RestoreGhostCascade() RemoveGhostCascade(); foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); + RemoveHiddenCascade(); ModelSystem.Variables.Remove(node); if (wasExposedInTemplate) owningTemplate!.RemoveExposedNode(node); @@ -1188,7 +1249,8 @@ void RestoreGhostCascade() } else { - // Node removal failed; roll back the link removals and ghost cascade. + // Node removal failed; roll back the link removals, hidden cascade, and ghost cascade. + RestoreHiddenCascade(); foreach (var link in outgoingLinks) boundary.AddLink(link, out _); RestoreGhostCascade(); diff --git a/tests/XTMF2.UnitTests/Editing/TestNode.cs b/tests/XTMF2.UnitTests/Editing/TestNode.cs index 5a41460..9d30a7c 100644 --- a/tests/XTMF2.UnitTests/Editing/TestNode.cs +++ b/tests/XTMF2.UnitTests/Editing/TestNode.cs @@ -638,5 +638,69 @@ public void NodeLocation() Assert.AreEqual(newLocation, modules[0].Location); }); } + + /// + /// Verify that a plain RemoveNode call also deletes hidden (embedded) destination nodes + /// that were linked to by the removed node. + /// + [TestMethod] + public void RemoveNode_CascadesHiddenEmbeddedNodes() + { + TestHelper.RunInModelSystemContext("RemoveNode_CascadesHiddenEmbeddedNodes", (user, pSession, msSession) => + { + CommandError error = null; + var ms = msSession.ModelSystem; + var gBound = ms.GlobalBoundary; + + // AddNodeGenerateParameters creates the main node AND a hidden BasicParameter child. + Assert.IsTrue(msSession.AddNodeGenerateParameters(user, gBound, "Test", + typeof(SimpleParameterModule), Rectangle.Hidden, + out var mainNode, out var children, out error), error?.Message); + Assert.IsNotNull(children); + Assert.HasCount(1, children); + Assert.HasCount(2, gBound.Modules, "Expected main node + 1 hidden child."); + Assert.HasCount(1, gBound.Links, "Expected the owner→child link."); + + // Plain RemoveNode should cascade and remove the hidden child too. + Assert.IsTrue(msSession.RemoveNode(user, mainNode, out error), error?.Message); + Assert.IsEmpty(gBound.Modules, "Hidden child node should have been removed with its owner."); + Assert.IsEmpty(gBound.Links, "The owner→child link should have been removed."); + }); + } + + /// + /// Verify that undo/redo correctly restores and re-removes the hidden + /// embedded nodes deleted by RemoveNode. + /// + [TestMethod] + public void RemoveNode_CascadesHiddenEmbeddedNodes_UndoRedo() + { + TestHelper.RunInModelSystemContext("RemoveNode_CascadesHiddenEmbeddedNodes_UndoRedo", (user, pSession, msSession) => + { + CommandError error = null; + var ms = msSession.ModelSystem; + var gBound = ms.GlobalBoundary; + + Assert.IsTrue(msSession.AddNodeGenerateParameters(user, gBound, "Test", + typeof(SimpleParameterModule), Rectangle.Hidden, + out var mainNode, out _, out error), error?.Message); + Assert.HasCount(2, gBound.Modules); + Assert.HasCount(1, gBound.Links); + + Assert.IsTrue(msSession.RemoveNode(user, mainNode, out error), error?.Message); + Assert.IsEmpty(gBound.Modules, "After RemoveNode both nodes should be gone."); + Assert.IsEmpty(gBound.Links); + + // Undo should bring back both the main node and the hidden child. + Assert.IsTrue(msSession.Undo(user, out error), error?.Message); + Assert.HasCount(2, gBound.Modules, "Undo should restore the main node and its hidden child."); + Assert.HasCount(1, gBound.Links, "Undo should restore the owner→child link."); + + // Redo should delete them both again. + Assert.IsTrue(msSession.Redo(user, out error), error?.Message); + Assert.IsEmpty(gBound.Modules, "Redo should remove both nodes again."); + Assert.IsEmpty(gBound.Links, "Redo should remove the link again."); + }); + } } } From 13092f2c407bce077ac45a4171cd4e0018ab4ff1 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 2 Apr 2026 23:10:51 -0400 Subject: [PATCH 05/41] Added delete a single option for links. --- .../ViewModels/ModelSystemEditorViewModel.cs | 17 +++++++++++++++++ .../Views/ModelSystemEditorView.axaml | 17 ++++++++++++++++- .../Views/ModelSystemEditorView.axaml.cs | 18 +++++++++++++++--- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 0fdf7be..a2e939a 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -2090,6 +2090,23 @@ public void MoveLinkDestination(int fromIndex, int toIndex) _ = ShowError("Move Failed", error); } + /// + /// Removes a single entry from the currently selected MultiLink. + /// Bound to the per-row delete button in the destination list. + /// + [RelayCommand] + private void RemoveLinkDestinationEntry(LinkDestinationViewModel? dest) + { + if (dest is null) return; + if (SelectedLink?.UnderlyingLink is not MultiLink multiLink) return; + var idx = SelectedLinkDestinationEntries.IndexOf(dest); + if (idx < 0) return; + if (SelectedLinkDestinationEntry == dest) + SelectedLinkDestinationEntry = null; + if (!Session.RemoveLinkDestination(User, multiLink, idx, out var error) && error is not null) + _ = ShowError("Remove Failed", error); + } + /// /// Sets on every VM whose /// equals . diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 1a394d2..19a5d42 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -346,7 +346,22 @@ MaxHeight="180"> - + + + private (string text, IBrush brush)[] _scriptTokens = Array.Empty<(string, IBrush)>(); + /// Internal ScrollViewer of , cached to allow unsubscription. + private ScrollViewer? _inlineEditorSv; + /// PropertyChanged handler subscribed to while editing a scripted parameter. + private EventHandler? _inlineEditorSvHandler; /// true while is executing, used to suppress re-entrant LostFocus commits. private bool _commitParamEditInProgress; @@ -3129,6 +3133,19 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) /// Override X position of the editor overlay (use -1 to auto-derive). /// Override Y position of the editor overlay (use -1 to auto-derive). /// Override width of the editor overlay (use -1 to auto-derive). + /// + /// Unsubscribes from the inline editor's internal ScrollViewer PropertyChanged event + /// and clears the cached references. Safe to call when not subscribed. + /// + private void UnsubscribeInlineEditorScroll() + { + if (_inlineEditorSv is not null && _inlineEditorSvHandler is not null) + _inlineEditorSv.PropertyChanged -= _inlineEditorSvHandler; + _inlineEditorSv = null; + _inlineEditorSvHandler = null; + _scriptOverlay.HorizontalScrollOffset = 0; + } + private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = -1, double rowW = -1) { HideVarDropdown(); @@ -3148,6 +3165,32 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = _scriptTokens = TokenizeScript(node.ParameterValueRepresentation); _scriptOverlay.Tokens = _scriptTokens; _scriptOverlay.IsVisible = true; + + // Subscribe to the TextBox's internal ScrollViewer so the overlay shifts + // horizontally in lockstep with the TextBox after every caret move or + // text change (the scroll happens during layout, after TextChanged fires). + UnsubscribeInlineEditorScroll(); + // The internal SV may not exist until after the first layout pass, so + // we post the subscription to run once the visual tree is populated. + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var sv = _inlineEditor.GetVisualDescendants() + .OfType() + .FirstOrDefault(); + if (sv is not null) + { + _inlineEditorSvHandler = (_, args) => + { + if (args.Property == ScrollViewer.OffsetProperty) + { + _scriptOverlay.HorizontalScrollOffset = sv.Offset.X; + _scriptOverlay.InvalidateVisual(); + } + }; + _inlineEditorSv = sv; + sv.PropertyChanged += _inlineEditorSvHandler; + } + }, Avalonia.Threading.DispatcherPriority.Loaded); } else { @@ -3200,6 +3243,7 @@ private void CommitParamEdit() } // Save succeeded – close the editor. + UnsubscribeInlineEditorScroll(); _editingParamNode = null; _inlineEditor.IsVisible = false; _inlineEditor.Foreground = ParamValueTextBrush; @@ -3220,6 +3264,7 @@ private void CommitParamEdit() private void CancelParamEdit() { HideVarDropdown(); + UnsubscribeInlineEditorScroll(); _editingParamNode = null; _inlineEditor.IsVisible = false; _inlineEditor.Foreground = ParamValueTextBrush; @@ -3404,6 +3449,8 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) { _scriptTokens = TokenizeScript(_inlineEditor.Text ?? string.Empty); _scriptOverlay.Tokens = _scriptTokens; + // Offset is kept current by the _inlineEditorScrollSub observable subscription; + // just redraw with the already-known offset. _scriptOverlay.InvalidateVisual(); } else diff --git a/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs b/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs index dd3ea24..d0114ac 100644 --- a/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs +++ b/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs @@ -36,6 +36,13 @@ internal sealed class ScriptSyntaxOverlay : Control public (string text, IBrush brush)[] Tokens { get; set; } = Array.Empty<(string, IBrush)>(); + /// + /// Horizontal pixel offset from the TextBox's internal ScrollViewer, used to + /// align the rendered text with what the TextBox actually shows. Set by + /// whenever the editor text changes. + /// + public double HorizontalScrollOffset { get; set; } + /// /// Scaled font size to use when drawing; updated by /// on every layout pass so the text matches the current zoom level. @@ -83,6 +90,11 @@ public override void Render(DrawingContext ctx) } double ty = (Bounds.Height - ft.Height) / 2.0; - ctx.DrawText(ft, new Point(leftPad, ty)); + + // Clip to the overlay bounds so text never bleeds past the TextBox edge, + // then translate left by the TextBox's horizontal scroll offset so the + // visible window of characters matches what the TextBox itself shows. + using var _clip = ctx.PushClip(new Rect(0, 0, Bounds.Width, Bounds.Height)); + ctx.DrawText(ft, new Point(leftPad - HorizontalScrollOffset, ty)); } } From 7017427915c2afb49d65a560672128f008613ef8 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 01:31:49 -0400 Subject: [PATCH 11/41] Variable hints while editing a script now scale with the canvas --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index eeab9ac..b46a3e0 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -665,7 +665,7 @@ protected override Size MeasureOverride(Size availableSize) if (_varDropdownVisible && _editingParamNode is not null) { double ddW = Math.Max(180.0, _editingParamEditorW) * _scale; - _varDropdownBorder.Measure(new Size(ddW, 200)); + _varDropdownBorder.Measure(new Size(ddW, 200 * _scale)); } // Measure the comment editor. if (_editingCommentBlock is not null) @@ -710,6 +710,17 @@ protected override Size ArrangeOverride(Size finalSize) double ddW = Math.Max(180.0, _editingParamEditorW) * _scale; double ddX = _editingParamEditorX * _scale; double ddY = (_editingParamEditorY + HookRowHeight) * _scale; + // Scale font size and padding of every suggestion row to match the current zoom. + double itemPadH = 8.0 * _scale; + double itemPadV = 3.0 * _scale; + foreach (var child in _varDropdownStack.Children) + { + if (child is TextBlock tb) + { + tb.FontSize = HookFontSize * _scale; + tb.Padding = new Thickness(itemPadH, itemPadV, itemPadH, itemPadV); + } + } _varDropdownBorder.Arrange(new Rect(ddX, ddY, ddW, _varDropdownBorder.DesiredSize.Height)); } // Position the comment editor over the comment block being edited. From 72062846f26f6ec91cb4cfc4095c9ca4afd7c03a Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 01:45:34 -0400 Subject: [PATCH 12/41] Moved adding items to canvas to context menu --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 37 ++++- .../ViewModels/ModelSystemEditorViewModel.cs | 136 ++++++++++++++++++ .../Views/ModelSystemEditorView.axaml | 36 +---- 3 files changed, 177 insertions(+), 32 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index b46a3e0..f8f191b 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -2691,8 +2691,7 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) var rdx = rPos.X - _rightClickPressPos.X; var rdy = rPos.Y - _rightClickPressPos.Y; - if (Math.Sqrt(rdx * rdx + rdy * rdy) < 3.0 - && (_rightClickElement is not null || _rightClickLink is not null)) + if (Math.Sqrt(rdx * rdx + rdy * rdy) < 3.0) { _linkOrigin = null; e.Pointer.Capture(null); @@ -2859,7 +2858,39 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) /// private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) { - if (_vm is null || (element is null && link is null)) return; + if (_vm is null) return; + + // ── Background right-click: offer Add items when nothing was hit ── + if (element is null && link is null) + { + var bgMenu = new ContextMenu(); + var vm2 = _vm; + var spawnPt = ToCanvasPos(_rightClickPressPos); + + var addStartItem = new MenuItem { Header = "Add Start…" }; + addStartItem.Click += (_, _) => _ = vm2.AddStartAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addStartItem); + + var addModuleItem = new MenuItem { Header = "Add Module…" }; + addModuleItem.Click += (_, _) => _ = vm2.AddModuleAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addModuleItem); + + var addCommentItem = new MenuItem { Header = "Add Comment" }; + addCommentItem.Click += (_, _) => vm2.AddCommentBlockAt(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addCommentItem); + + var addFtItem = new MenuItem { Header = "Add Function Template…" }; + addFtItem.Click += (_, _) => _ = vm2.AddFunctionTemplateAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addFtItem); + + var addFiItem = new MenuItem { Header = "Add Function Instance…" }; + addFiItem.Click += (_, _) => _ = vm2.AddFunctionInstanceAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addFiItem); + + ContextMenu = bgMenu; + ContextMenu.Open(this); + return; + } var vm = _vm; // capture for closure diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index a2e939a..d48138f 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -2249,6 +2249,142 @@ private Rectangle NextPlacement(int existingCount) return new Rectangle(50 + offset % 800, 50 + (offset / 800) * PlacementStep); } + // ── Position-aware add helpers (called from the canvas background context menu) ─ + + /// Prompt for a name and add a new Start at the specified canvas position. + public async Task AddStartAtAsync(double x, double y) + { + if (ParentWindow is null) return; + + var dialog = new InputDialog + { + Prompt = "Enter start name:", + InputText = $"Start {++_startCounter}" + }; + await dialog.ShowDialog(ParentWindow); + + var name = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name)) return; + + var location = new Rectangle((float)x, (float)y); + Session.AddModelSystemStart(User, _currentBoundary, name, location, out _, out _); + } + + /// Show a type-picker then a name dialog and add a new module node at the specified canvas position. + public async Task AddModuleAtAsync(double x, double y) + { + if (ParentWindow is null) return; + + var typePicker = new TypePickerDialog( + Session.LoadedModuleTypes, + prompt: "Select the module type to add:"); + await typePicker.ShowDialog(ParentWindow); + + if (typePicker.WasCancelled || typePicker.SelectedType is null) return; + var selectedType = typePicker.SelectedType; + + var nameDialog = new InputDialog( + title: "Add Module", + prompt: "Enter module name:", + defaultText: selectedType.Name); + await nameDialog.ShowDialog(ParentWindow); + + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + var location = new Rectangle((float)x, (float)y); + Session.AddNodeGenerateParameters(User, _currentBoundary, name, selectedType, location, out _, out _, out _); + } + + /// Add a new comment block at the specified canvas position. + public void AddCommentBlockAt(double x, double y) + { + var location = new Rectangle((float)x, (float)y, + (float)CommentBlockViewModel.DefaultWidth, + (float)CommentBlockViewModel.DefaultHeight); + Session.AddCommentBlock(User, _currentBoundary, $"Comment {++_commentCounter}", location, out _, out _); + } + + /// Prompt for a name and create a new function template at the specified canvas position. + public async Task AddFunctionTemplateAtAsync(double x, double y) + { + if (ParentWindow is null) return; + + var dialog = new InputDialog( + title: "Add Function Template", + prompt: "Enter the function template name:", + defaultText: $"FunctionTemplate{FunctionTemplates.Count + 1}"); + await dialog.ShowDialog(ParentWindow); + + var name = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || dialog.WasCancelled) return; + + var ftLocation = new Rectangle((float)x, (float)y, 220f, 140f); + + if (!Session.AddFunctionTemplate(User, _currentBoundary, name, + out var ft, out var error)) + { + await ShowError("Add Function Template Failed", error); + return; + } + + Session.SetFunctionTemplateLocation(User, ft!, ftLocation, out _); + } + + /// Prompt the user to pick a template and a name then place a new function instance at the specified canvas position. + public async Task AddFunctionInstanceAtAsync(double x, double y) + { + if (ParentWindow is null) return; + + var availableTemplates = new System.Collections.Generic.List(); + _currentBoundary.CollectAccessibleFunctionTemplates(availableTemplates); + if (availableTemplates.Count == 0) + { + ShowToast("No function templates are defined in this boundary or its children.", isError: true, durationMs: 4000); + return; + } + + var templateDisplayNames = availableTemplates + .Select(ft => Boundary.GetQualifiedTemplateName(_currentBoundary, ft) ?? ft.Name) + .ToList(); + + FunctionTemplate selectedTemplate; + if (availableTemplates.Count == 1) + { + selectedTemplate = availableTemplates[0]; + } + else + { + var picker = new StartPickerDialog( + title: "Add Function Instance", + prompt: "Select the function template to instantiate:", + startNames: templateDisplayNames, + defaultStart: templateDisplayNames[0]); + await picker.ShowDialog(ParentWindow); + if (picker.WasCancelled) return; + var picked = picker.SelectedStartName ?? templateDisplayNames[0]; + var pickedIdx = templateDisplayNames.IndexOf(picked); + selectedTemplate = availableTemplates[pickedIdx >= 0 ? pickedIdx : 0]; + } + + var nameDialog = new InputDialog( + title: "Add Function Instance", + prompt: $"Enter the instance name ({selectedTemplate.Name}):", + defaultText: $"{selectedTemplate.Name}{FunctionInstances.Count + 1}"); + await nameDialog.ShowDialog(ParentWindow); + + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + var location = new Rectangle((float)x, (float)y, 160f, 70f); + + if (!Session.AddFunctionInstance(User, _currentBoundary, selectedTemplate, name, location, + out _, out var addError)) + { + await ShowError("Add Function Instance Failed", addError); + } + } + // ── IDisposable ─────────────────────────────────────────────────────── /// public void Dispose() diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 19a5d42..d296d5d 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -140,39 +140,17 @@ - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - /// Handles the "Rename" button click in the function-template properties panel - /// by starting an inline header edit on the canvas (instead of a modal dialog). - /// - private void OnRenameFunctionTemplateClick(object? sender, RoutedEventArgs e) - { - TheCanvas.BeginNameEditForSelected(); - TheCanvas.Focus(); - } - - /// - /// Handles the "Rename" button click in the function-instance properties panel - /// by starting an inline header edit on the canvas (instead of a modal dialog). - /// - private void OnRenameFunctionInstanceClick(object? sender, RoutedEventArgs e) - { - TheCanvas.BeginNameEditForSelected(); - TheCanvas.Focus(); - } - - private void OnParameterValueEditBoxKeyDown(object? sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) - _vm?.CommitParameterValueCommand.Execute(null); - } + // -- Visual-tree / DataContext lifecycle ------------------------------------ private void OnAttachedToVisualTree(object? sender, Avalonia.VisualTreeAttachmentEventArgs e) { - // Give the VM a reference to the top-level window for showing dialogs. if (_vm is not null) _vm.ParentWindow = TopLevel.GetTopLevel(this) as Window; + UpdateThemeClass(); } private void OnDataContextChanged(object? sender, System.EventArgs e) { - // Unsubscribe from the old VM. if (_vm is not null) { _vm.PropertyChanged -= OnVmPropertyChanged; @@ -156,7 +119,6 @@ private void OnDataContextChanged(object? sender, System.EventArgs e) _vm = DataContext as ModelSystemEditorViewModel; - // Provide the parent window immediately if we are already in the tree. if (_vm is not null) { _vm.ParentWindow = TopLevel.GetTopLevel(this) as Window; @@ -170,14 +132,16 @@ private void OnScrollToNodeRequested(NodeViewModel node) var viewport = CanvasScrollViewer.Viewport; var offsetX = node.X + node.Width / 2.0 - viewport.Width / 2.0; var offsetY = node.Y + node.Height / 2.0 - viewport.Height / 2.0; - CanvasScrollViewer.Offset = new Avalonia.Vector( + CanvasScrollViewer.Offset = new Vector( Math.Max(0, offsetX), Math.Max(0, offsetY)); TheCanvas.Focus(); } - // Set when the Enter-key handler has already committed a selection, so that the - // subsequent DropDownClosed event does not commit it a second time. + private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e) { } + + // -- Node search box -------------------------------------------------------- + private bool _suppressNextDropDownClose; private void OnNodeSearchBoxDropDownClosed(object? sender, EventArgs e) @@ -191,13 +155,12 @@ private void OnNodeSearchBoxKeyDown(object? sender, KeyEventArgs e) { if (e.Key != Key.Enter || _vm is null) return; - // Prefer the item explicitly highlighted in the dropdown; fall back to text search. var nodeVm = NodeSearchBox.SelectedItem as NodeViewModel ?? _vm.Nodes.FirstOrDefault(n => n.Name.Contains(NodeSearchBox.Text ?? string.Empty, StringComparison.OrdinalIgnoreCase)); - _suppressNextDropDownClose = true; // DropDownClosed will fire after Enter + _suppressNextDropDownClose = true; if (nodeVm is not null) _vm.NodeSearchSelection = nodeVm; @@ -207,13 +170,7 @@ private void OnNodeSearchBoxKeyDown(object? sender, KeyEventArgs e) e.Handled = true; } - private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - // Nothing needs code-behind attention at present; - // all property panel labels are XAML-bound. - } - - // ── Boundary navigation dropdown ────────────────────────────────────── + // -- Boundary navigation dropdown ------------------------------------------- private void OnBoundaryNavSelectionChanged(object? sender, SelectionChangedEventArgs e) { @@ -224,7 +181,6 @@ private void OnBoundaryNavSelectionChanged(object? sender, SelectionChangedEvent return; } - // Always reset immediately — the current boundary name shows as the placeholder text. cb.SelectedIndex = -1; if (item.IsBrowse) @@ -233,133 +189,29 @@ private void OnBoundaryNavSelectionChanged(object? sender, SelectionChangedEvent _vm?.SwitchToBoundary(boundary); } - // ── Destination list drag-and-drop (pointer-based, no DragDrop API) ── + // -- Floating dock: Variables button ---------------------------------------- - private void OnDestListDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) - { - if (_vm is null) return; - if (e.Source is Control src && src.DataContext is LinkDestinationViewModel item) - _vm.NavigateToLinkDestination(item); - } + private ModelSystemVariablesDialog? _variablesDialog; - private void OnDestListPointerPressed(object? sender, PointerPressedEventArgs e) + private void OnShowVariablesClick(object? sender, RoutedEventArgs e) { - _destDragging = false; - _destActiveDragFrom = -1; - if (e.Source is Control src && src.DataContext is LinkDestinationViewModel item) - { - _destDragIndex = _vm?.SelectedLinkDestinationEntries.IndexOf(item) ?? -1; - _destDragStartY = e.GetPosition(DestinationListBox).Y; - if (_destDragIndex >= 0) - e.Pointer.Capture(DestinationListBox); - } - else - { - _destDragIndex = -1; - } - } - - private void OnDestListPointerMoved(object? sender, PointerEventArgs e) - { - if (_destDragIndex < 0) return; - var pt = e.GetCurrentPoint(DestinationListBox); - if (!pt.Properties.IsLeftButtonPressed) - { - _destDragIndex = -1; - HideDragIndicator(); - return; - } - - // Don't commit to a drag until the pointer has moved enough to be intentional. - if (!_destDragging) - { - if (Math.Abs(pt.Position.Y - _destDragStartY) < DestDragThreshold) - return; - _destDragging = true; - _destActiveDragFrom = _destDragIndex; - } - - UpdateDragIndicator(pt.Position); - } - - private void OnDestListPointerReleased(object? sender, PointerReleasedEventArgs e) - { - e.Pointer.Capture(null); - HideDragIndicator(); - - if (!_destDragging || _destActiveDragFrom < 0) - { - _destDragIndex = -1; - _destDragging = false; - return; - } - - var fromIndex = _destActiveDragFrom; - var insertBefore = GetDropInsertIndex(e.GetPosition(DestinationListBox)); - - // MoveDestination(from, to) removes the item first then inserts at 'to', so the - // effective target index shifts by -1 whenever the source was before the insert point. - var toIndex = fromIndex < insertBefore ? insertBefore - 1 : insertBefore; - toIndex = Math.Clamp(toIndex, 0, DestinationListBox.ItemCount - 1); - - if (_vm is not null && fromIndex != toIndex) - _vm.MoveLinkDestination(fromIndex, toIndex); - - _destDragIndex = -1; - _destDragging = false; - _destActiveDragFrom = -1; - } + if (_vm is null) return; - /// - /// Returns the "insert before" index (0 = before the first item, ItemCount = append after the last). - /// Used for both computing the drop target and positioning the indicator. - /// - private int GetDropInsertIndex(Avalonia.Point dropPos) - { - for (int i = 0; i < DestinationListBox.ItemCount; i++) + if (_variablesDialog is null || !_variablesDialog.IsVisible) { - if (DestinationListBox.ContainerFromIndex(i) is not Control container) continue; - // TranslatePoint converts the container's origin into the ListBox coordinate space, - // which matches `dropPos` obtained via e.GetCurrentPoint(DestinationListBox). - var topInListBox = container.TranslatePoint(new Avalonia.Point(0, 0), DestinationListBox); - if (topInListBox is null) continue; - var mid = topInListBox.Value.Y + container.Bounds.Height / 2.0; - if (dropPos.Y < mid) - return i; - } - return DestinationListBox.ItemCount; // append to end - } + _variablesDialog = new ModelSystemVariablesDialog(_vm); - /// Show the drop-indicator line at the position implied by the current pointer. - private void UpdateDragIndicator(Avalonia.Point posInListBox) - { - int insertBefore = GetDropInsertIndex(posInListBox); + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is not null) + _variablesDialog.Show(owner); + else + _variablesDialog.Show(); - double? indicatorY = null; - if (insertBefore < DestinationListBox.ItemCount) - { - if (DestinationListBox.ContainerFromIndex(insertBefore) is Control c) - { - var topLeft = c.TranslatePoint(new Avalonia.Point(0, 0), DestinationListBox); - if (topLeft is not null) indicatorY = topLeft.Value.Y; - } + _variablesDialog.Closed += (_, _) => _variablesDialog = null; } - else if (DestinationListBox.ItemCount > 0) + else { - if (DestinationListBox.ContainerFromIndex(DestinationListBox.ItemCount - 1) is Control last) - { - var topLeft = last.TranslatePoint(new Avalonia.Point(0, 0), DestinationListBox); - if (topLeft is not null) indicatorY = topLeft.Value.Y + last.Bounds.Height; - } + _variablesDialog.Activate(); } - - if (indicatorY is null) return; - - DestDropIndicator.Width = DestinationListBox.Bounds.Width; - Avalonia.Controls.Canvas.SetTop(DestDropIndicator, indicatorY.Value - 1); - DestDropIndicator.IsVisible = true; } - - private void HideDragIndicator() => DestDropIndicator.IsVisible = false; } - diff --git a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml new file mode 100644 index 0000000..650b5bc --- /dev/null +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs new file mode 100644 index 0000000..8d6fabb --- /dev/null +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs @@ -0,0 +1,69 @@ +/* + 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 Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using XTMF2.GUI.ViewModels; + +namespace XTMF2.GUI.Views; + +/// +/// Non-modal floating dialog that displays and manages the model system's +/// variable list. Opened from the floating action bar in ModelSystemEditorView. +/// +public partial class ModelSystemVariablesDialog : Window +{ + private readonly ModelSystemEditorViewModel _vm = null!; + + /// Required by the Avalonia AXAML compiler (design-time only). + public ModelSystemVariablesDialog() + { + InitializeComponent(); + } + + /// Runtime constructor — pass the editor view-model as DataContext. + public ModelSystemVariablesDialog(ModelSystemEditorViewModel vm) + { + _vm = vm; + InitializeComponent(); + DataContext = vm; + + // Escape clears the variable filter; second Escape closes the window. + FilterBox.KeyDown += OnFilterBoxKeyDown; + } + + private void OnFilterBoxKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + if (!string.IsNullOrEmpty(_vm.VariableFilter)) + { + _vm.VariableFilter = string.Empty; + e.Handled = true; + } + else + { + Close(); + e.Handled = true; + } + } + } + + private void OnCloseClick(object? sender, RoutedEventArgs e) => Close(); +} From 76fb3ddf8f40674952b5e27ee8e2547bc37a8ff0 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 11:04:28 -0400 Subject: [PATCH 18/41] Added icons to document tab headers to help identify them. --- src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs | 2 +- src/XTMF2.GUI/ViewModels/ModelSystemsViewModel.cs | 2 +- src/XTMF2.GUI/ViewModels/ProjectsViewModel.cs | 2 +- src/XTMF2.GUI/ViewModels/RunsViewModel.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 5360c3d..584aabd 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -121,7 +121,7 @@ public string CurrentBoundaryLabel // ── Dock integration ────────────────────────────────────────────────── /// Tab title shown in the dock. - public string Title => ModelSystemHeader.Name ?? "Model System"; + public string Title => $"✎ {ModelSystemHeader.Name ?? "Model System"}"; /// Allow the user to close this tab. public bool CanClose => true; diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemsViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemsViewModel.cs index 66bd1cc..246dc86 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemsViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemsViewModel.cs @@ -43,7 +43,7 @@ public partial class ModelSystemsViewModel : ObservableObject, IDisposable private Window? _parentWindow; // Used by Dock ItemsSource for the tab title and close behaviour - public string Title => _project.Name ?? "Untitled Project"; + public string Title => $"⊟ {_project.Name ?? "Untitled Project"}"; public bool CanClose => true; /// Gets the project this view model is for. diff --git a/src/XTMF2.GUI/ViewModels/ProjectsViewModel.cs b/src/XTMF2.GUI/ViewModels/ProjectsViewModel.cs index 9ffc1d5..d374d7d 100644 --- a/src/XTMF2.GUI/ViewModels/ProjectsViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ProjectsViewModel.cs @@ -29,7 +29,7 @@ public partial class ProjectsViewModel : ObservableObject private readonly User? _currentUser; // Used by Dock ItemsSource for the tab title and close behaviour - public string Title => "Projects"; + public string Title => "⊞ Projects"; public bool CanClose => false; [ObservableProperty] diff --git a/src/XTMF2.GUI/ViewModels/RunsViewModel.cs b/src/XTMF2.GUI/ViewModels/RunsViewModel.cs index f282f35..eefe407 100644 --- a/src/XTMF2.GUI/ViewModels/RunsViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/RunsViewModel.cs @@ -34,7 +34,7 @@ public sealed partial class RunsViewModel : ObservableObject { // ── Dock integration ────────────────────────────────────────────────── /// Tab title shown in the dock. - public string Title => "Runs"; + public string Title => "▶ Runs"; /// The Runs tab is permanent; do not allow users to close it. public bool CanClose => false; From 54d867964aa30f5d128a2e258c6ba11666e4ffe4 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 11:19:46 -0400 Subject: [PATCH 19/41] Added UX for moving to parent boundary --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 14 ++++++++ .../ViewModels/ModelSystemEditorViewModel.cs | 33 +++++++++++++++++++ .../Views/ModelSystemEditorView.axaml | 10 ++++++ 3 files changed, 57 insertions(+) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index eb2cf7d..8a3f796 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -2278,6 +2278,12 @@ protected override void OnKeyDown(KeyEventArgs e) e.Handled = true; } } + else if (e.Key == Key.Left && (e.KeyModifiers & KeyModifiers.Alt) != 0) + { + // Alt+Left: navigate to parent boundary / exit function template. + _vm?.NavigateUpCommand.Execute(null); + e.Handled = true; + } } // ── Scaling helpers ─────────────────────────────────────────────────── @@ -2369,6 +2375,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) base.OnPointerPressed(e); if (_vm is null) return; + // ── Mouse back button (XButton1): navigate to parent scope ─────────────── + if (e.GetCurrentPoint(this).Properties.PointerUpdateKind == PointerUpdateKind.XButton1Pressed) + { + _vm.NavigateUpCommand.Execute(null); + e.Handled = true; + return; + } + var point = e.GetCurrentPoint(this); var pos = point.Position; // screen coords var mpos = ToCanvasPos(pos); // model coords diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 584aabd..901f71b 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -530,6 +530,8 @@ public void SwitchToBoundary(Boundary boundary) ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); } + NavigateUpCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigateUp)); SubscribeToBoundary(_currentBoundary); BuildFromBoundary(_currentBoundary); RebuildBoundaryNavItems(); @@ -1688,6 +1690,8 @@ public void NavigateIntoFunctionTemplate(FunctionTemplateViewModel ftvm) _currentFunctionTemplate = ftvm; OnPropertyChanged(nameof(IsInsideFunctionTemplate)); ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + NavigateUpCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigateUp)); SwitchToBoundary(ftvm.UnderlyingTemplate.InternalModules); } @@ -1758,9 +1762,38 @@ private void ExitFunctionTemplate() _currentFunctionTemplate = null; OnPropertyChanged(nameof(IsInsideFunctionTemplate)); ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + NavigateUpCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigateUp)); SwitchToBoundary(parentBoundary); } + // ── General navigate-up (function template exit OR parent boundary) ────── + + /// + /// true when the user can navigate up: either inside a function template + /// or viewing a non-root boundary. + /// + public bool CanNavigateUp => IsInsideFunctionTemplate || !IsAtRootBoundary; + + /// + /// Navigates up one scope. If the canvas is inside a function template the + /// user is returned to that template's parent boundary. Otherwise the canvas + /// ascends to . No-op when already at the global + /// root and not inside a function template. + /// + [RelayCommand(CanExecute = nameof(CanNavigateUp))] + private void NavigateUp() + { + if (IsInsideFunctionTemplate) + { + ExitFunctionTemplate(); + return; + } + var parent = _currentBoundary.Parent; + if (parent is not null) + SwitchToBoundary(parent); + } + /// /// Toggles whether (a node in the current /// ) is exposed as an external hook diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 0a46e98..936fa23 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -59,6 +59,7 @@ + @@ -157,6 +158,15 @@ + + + + + @@ -172,7 +191,20 @@ IsEnabled="{Binding CanUndo}" ToolTip.Tip="Undo (Ctrl+Z)" ToolTip.Placement="Top"> - + + + + + + + + @@ -180,7 +212,20 @@ IsEnabled="{Binding CanRedo}" ToolTip.Tip="Redo (Ctrl+Y)" ToolTip.Placement="Top"> - + + + + + + + + @@ -189,21 +234,60 @@ @@ -213,7 +297,19 @@ IsEnabled="{Binding CanRun}" ToolTip.Tip="Run this model system (save first to apply changes)" ToolTip.Placement="Top"> - + + + + + + + + @@ -222,7 +318,25 @@ From ec1b6c346e88c926176140642879a5da624b5912 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 11:41:15 -0400 Subject: [PATCH 21/41] Add light theme support for ModelSystemVariablesDialog --- .../Views/ModelSystemVariablesDialog.axaml | 92 ++++++++++++++----- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml index 650b5bc..964fe63 100644 --- a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml @@ -10,54 +10,104 @@ MinWidth="320" MinHeight="280" WindowStartupLocation="CenterOwner" - Background="#0E0E18" - BorderBrush="#FF00D4FF" + Background="{DynamicResource VarDlgBg}" + BorderBrush="{DynamicResource VarDlgBorder}" BorderThickness="1.5"> + + + + + + #0E0E18 + #FF00D4FF + #FFEEFFFF + #FF00D4FF + #2200D4FF + #4400D4FF + #3300D4FF + #8800D4FF + #FFFF4466 + #22FF4466 + #12FFFFFF + #4400D4FF + #1500D4FF + #2500D4FF + #2200D4FF + #2200D4FF + #4400D4FF + #FF00D4FF + + + + #F2F6FF + #FF0066CC + #FF001830 + #FF0066CC + #1A0066CC + #330066CC + #550066CC + #AA0066CC + #FFCC0022 + #22CC0022 + #0A000000 + #550066CC + #150066CC + #250066CC + #330066CC + #330066CC + #550066CC + #FF0066CC + + + + + + @@ -68,7 +118,7 @@ + Foreground="{DynamicResource VarDlgAccent}"/> @@ -97,7 +147,7 @@ IsVisible="{Binding HasNoModelSystemVariables}"> @@ -163,7 +213,7 @@ @@ -176,7 +226,7 @@ From e01ef8360c42fa157fdd3ef00a0e8328aaf65426 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Fri, 3 Apr 2026 11:58:16 -0400 Subject: [PATCH 22/41] Unify themes across all dialogs --- src/XTMF2.GUI/App.axaml | 141 +++++++++++++++++- src/XTMF2.GUI/Views/AboutDialog.axaml | 2 + .../Views/BoundaryPickerDialog.axaml | 8 +- src/XTMF2.GUI/Views/ConfirmDialog.axaml | 2 + src/XTMF2.GUI/Views/HookPickerDialog.axaml | 6 +- src/XTMF2.GUI/Views/InputDialog.axaml | 2 + .../Views/InterBoundaryLinkDialog.axaml | 13 +- src/XTMF2.GUI/Views/MessageDialog.axaml | 2 + .../Views/ModelSystemVariablesDialog.axaml | 124 ++------------- .../Views/ParameterEditorDialog.axaml | 6 +- src/XTMF2.GUI/Views/StartPickerDialog.axaml | 2 + src/XTMF2.GUI/Views/TypePickerDialog.axaml | 6 +- 12 files changed, 179 insertions(+), 135 deletions(-) diff --git a/src/XTMF2.GUI/App.axaml b/src/XTMF2.GUI/App.axaml index c259e87..ba577aa 100644 --- a/src/XTMF2.GUI/App.axaml +++ b/src/XTMF2.GUI/App.axaml @@ -25,7 +25,64 @@ - + + + + + + #0E0E18 + #FF00D4FF + #FFEEFFFF + #99AABBCC + #FF00D4FF + #2200D4FF + #4400D4FF + #3300D4FF + #8800D4FF + #FFFF4466 + #22FF4466 + #FFFF4466 + #12FFFFFF + #4400D4FF + #1500D4FF + #2500D4FF + #2200D4FF + #2200D4FF + #FF00D4FF + #4400D4FF + #FF00D4FF + #FF000A0E + #CC00D4FF + + + + #F2F6FF + #FF0066CC + #FF001830 + #FF567890 + #FF0066CC + #1A0066CC + #330066CC + #550066CC + #AA0066CC + #FFCC0022 + #22CC0022 + #FFCC0022 + #0A000000 + #550066CC + #150066CC + #250066CC + #330066CC + #330066CC + #FF0066CC + #550066CC + #FF0066CC + #FFFFFFFF + #CC0066CC + + + + #2D5016 @@ -88,6 +145,86 @@ - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2.GUI/Views/AboutDialog.axaml b/src/XTMF2.GUI/Views/AboutDialog.axaml index 7dd7e9b..08837b9 100644 --- a/src/XTMF2.GUI/Views/AboutDialog.axaml +++ b/src/XTMF2.GUI/Views/AboutDialog.axaml @@ -5,6 +5,7 @@ xmlns:res="clr-namespace:XTMF2.GUI.Resources" mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="340" x:Class="XTMF2.GUI.Views.AboutDialog" + Classes="neon-dialog" Title="{res:Localize About_Title}" Width="420" SizeToContent="Height" @@ -78,6 +79,7 @@ diff --git a/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml b/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml index 5ec6901..cd6e0b6 100644 --- a/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml +++ b/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml @@ -5,6 +5,7 @@ x:Class="XTMF2.GUI.Views.ParameterEditorDialog" x:CompileBindings="True" x:DataType="local:ParameterEditorDialog" + Classes="neon-dialog" Title="Edit Parameter" Width="460" Height="240" @@ -17,7 +18,7 @@ @@ -44,7 +45,7 @@ Date: Sat, 4 Apr 2026 00:01:56 -0400 Subject: [PATCH 25/41] Button tooltips no longer steal focus and disapear for dock buttons --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 44 +++---- .../Views/ModelSystemEditorView.axaml | 107 +++++++++--------- .../Views/ModelSystemEditorView.axaml.cs | 2 +- 3 files changed, 79 insertions(+), 74 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 832b6b7..497d637 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -446,8 +446,8 @@ public ModelSystemCanvas() { FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), FontSize = 11, - Foreground = NodeTextBrush, - Background = new SolidColorBrush(Color.FromRgb(0x22, 0x32, 0x44)), + Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), + Background = new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)), BorderThickness = new Thickness(0), Padding = new Thickness(4, 1, 4, 1), Width = 52, @@ -463,7 +463,7 @@ public ModelSystemCanvas() FontSize = 13, Padding = new Thickness(6, 1, 6, 1), Background = Brushes.Transparent, - Foreground = NodeTextBrush, + Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), BorderThickness = new Thickness(0), }; _zoomMinusBtn.Click += (_, _) => ApplyScale(_scale - ScaleStep); @@ -474,18 +474,18 @@ public ModelSystemCanvas() FontSize = 13, Padding = new Thickness(6, 1, 6, 1), Background = Brushes.Transparent, - Foreground = NodeTextBrush, + Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), BorderThickness = new Thickness(0), }; _zoomPlusBtn.Click += (_, _) => ApplyScale(_scale + ScaleStep); _zoomBar = new Border { - Background = new SolidColorBrush(Color.FromArgb(0xCC, 0x1A, 0x1A, 0x2E)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(2), + Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)), + BorderThickness = new Thickness(1.5), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(4, 3), Child = new StackPanel { Orientation = Orientation.Horizontal, @@ -2180,21 +2180,23 @@ private void UpdateZoomBarColors(bool isLight) _zoomBarIsLight = isLight; if (isLight) { - _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xE8, 0xF0, 0xF4, 0xFF)); - _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xBB, 0xCC)); - _zoomTextBox.Background = new SolidColorBrush(Color.FromRgb(0xF0, 0xF4, 0xFB)); - _zoomTextBox.Foreground = Brushes.Black; - _zoomMinusBtn.Foreground = Brushes.Black; - _zoomPlusBtn.Foreground = Brushes.Black; + // Light neon pill palette + _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xF4, 0xF8, 0xFF, 0xEE)); + _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xCC)); + _zoomTextBox.Background = new SolidColorBrush(Color.FromArgb(0x0A, 0x00, 0x00, 0x00)); + _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); + _zoomMinusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); + _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); } else { - _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xCC, 0x1A, 0x1A, 0x2E)); - _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)); - _zoomTextBox.Background = new SolidColorBrush(Color.FromRgb(0x22, 0x32, 0x44)); - _zoomTextBox.Foreground = NodeTextBrush; - _zoomMinusBtn.Foreground = NodeTextBrush; - _zoomPlusBtn.Foreground = NodeTextBrush; + // Dark neon pill palette + _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)); + _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)); + _zoomTextBox.Background = new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)); + _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); + _zoomMinusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); + _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); } } diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index da44dd8..86f8d2b 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -88,9 +88,9 @@ Grid.Column="1" ItemsSource="{Binding BoundaryNavigationItems}" PlaceholderText="{Binding CurrentBoundaryLabel}" - Width="200" + Width="250" VerticalAlignment="Center" - Margin="0,0,8,0" + Margin="0,0,16,0" ToolTip.Tip="Navigate to a boundary (Ctrl+B)"> @@ -105,18 +105,20 @@ MinimumPrefixLength="0" FilterMode="ContainsOrdinal" ValueMemberBinding="{Binding Name}" - Watermark="Find node… (Ctrl+E)" - Width="220" + Watermark="Find... (Ctrl+E)" + Width="250" VerticalAlignment="Center"/> - + - + - + ZIndex="30"> @@ -168,8 +170,8 @@ @@ -212,19 +215,20 @@ @@ -234,9 +238,9 @@ @@ -297,8 +300,8 @@ + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index fde25fe..a7e0d9e 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -1674,7 +1674,13 @@ public bool SetParameterExpression(User user, Node basicParameter, string expres } var previousType = basicParameter.Type; var previousValue = basicParameter.ParameterValue; - if(basicParameter.SetParameterExpression(ModelSystem.Variables, expression, out error)) + // Nodes inside a FunctionTemplate's InternalModules have template-local + // variables that shadow the global model-system variables. + var localVars = basicParameter.ContainedWithin?.OwningFunctionTemplate?.LocalVariables; + IList allVars = localVars is { Count: > 0 } + ? localVars.Concat(ModelSystem.Variables).ToList() + : (IList)ModelSystem.Variables; + if(basicParameter.SetParameterExpression(allVars, expression, out error)) { var newType = basicParameter.Type; var newExpression = basicParameter.ParameterValue; @@ -1738,8 +1744,7 @@ public bool AddVariable(User user, Node node, [NotNullWhen(false)] out CommandEr /// The node to remove. /// An error message if the operation fails. /// True if successful, false otherwise with an error message. - public bool RemoveVariable(User user, Node node, [NotNullWhen(false)] out CommandError? error) - { + public bool RemoveVariable(User user, Node node, [NotNullWhen(false)] out CommandError? error) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(node); @@ -1769,6 +1774,60 @@ public bool RemoveVariable(User user, Node node, [NotNullWhen(false)] out Comman } } + /// + /// Designates a node inside a 's + /// as a template-local variable. + /// Local variables are resolved before global model-system variables when compiling + /// scripted parameter expressions for nodes inside the same template. + /// Scripts outside the template cannot reference these variables. + /// + public bool AddFunctionTemplateVariable(User user, FunctionTemplate template, Node node, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(node); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + if (!template.AddLocalVariable(node, out error)) return false; + Buffer.AddUndo(new Command( + () => { template.RemoveLocalVariable(node, out _); return (true, null); }, + () => { template.AddLocalVariable(node, out _); return (true, null); })); + return true; + } + } + + /// + /// Removes a node from a 's local variable list. + /// + public bool RemoveFunctionTemplateVariable(User user, FunctionTemplate template, Node node, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(node); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + if (!template.RemoveLocalVariable(node, out error)) return false; + Buffer.AddUndo(new Command( + () => { template.AddLocalVariable(node, out _); return (true, null); }, + () => { template.RemoveLocalVariable(node, out _); return (true, null); })); + return true; + } + } + /// /// Set the node to the disabled state. /// diff --git a/src/XTMF2/ModelSystemConstruct/Boundary.cs b/src/XTMF2/ModelSystemConstruct/Boundary.cs index 8574abd..d6cae51 100644 --- a/src/XTMF2/ModelSystemConstruct/Boundary.cs +++ b/src/XTMF2/ModelSystemConstruct/Boundary.cs @@ -151,6 +151,13 @@ public ReadOnlyObservableCollection FunctionTemplates public ReadOnlyObservableCollection FunctionInstances => _functionInstancesView ??= new ReadOnlyObservableCollection(_functionInstances); + /// + /// When this boundary serves as the InternalModules of a + /// , this property returns that template. + /// null for all other boundaries. + /// + public FunctionTemplate? OwningFunctionTemplate { get; internal set; } + internal bool Validate(ref string? moduleName, ref string? error) { foreach (var module in _modules) diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs index 9293b5a..1116cba 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs @@ -47,6 +47,7 @@ public sealed class FunctionTemplate : INotifyPropertyChanged private const string LocationProperty = "Location"; private const string FunctionParametersProperty = "FunctionParameters"; private const string EntryNodeProperty = "EntryNode"; + private const string LocalVariablesProperty = "LocalVariables"; private const string LocationXProperty = "X"; private const string LocationYProperty = "Y"; private const string LocationWProperty = "Width"; @@ -118,6 +119,18 @@ internal void SetLocation(Rectangle location) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Location))); } + // ── Local variables (nodes in InternalModules that can be referenced by + // name in scripted parameter expressions inside the template) ──── + private readonly ObservableCollection _localVariables = new(); + + /// + /// Nodes inside that are designated as template-local + /// variables. These are resolved BEFORE when + /// compiling scripted expressions for nodes inside this template. Scripts on + /// boundaries outside the template cannot see these variables. + /// + public ReadOnlyObservableCollection LocalVariables { get; private set; } = null!; + // ── Function parameters ─────────────────────────────────────────── private readonly ObservableCollection _functionParameters = new(); @@ -231,7 +244,85 @@ public FunctionTemplate(string name, Boundary parent, Boundary? internalModules _name = name; Parent = parent; InternalModules = internalModules ?? new Boundary("InternalModules", parent); + // Register this template as the owner of InternalModules so that expression + // compilation can discover the local variable scope from any node inside it. + InternalModules.OwningFunctionTemplate = this; FunctionParameters = new ReadOnlyObservableCollection(_functionParameters); + LocalVariables = new ReadOnlyObservableCollection(_localVariables); + } + + // ── Local variable management (called by ModelSystemSession) ────── + + /// + /// Returns true if is eligible to be a local variable. + /// A node is eligible when it is inside and its effective + /// parameter type is one of the four basic types (bool, int, float, string), either + /// directly (e.g. the node is a BasicParameter<int>) or via + /// (e.g. a whose type is + /// IFunction<int>). + /// + public static bool IsValidLocalVariableNode(Node node, [NotNullWhen(false)] out CommandError? error) + { + var t = node is FunctionParameter fp ? ExtractIFunctionInnerType(fp.Type) : node.ParameterValue?.Type; + if (t is null) + { + error = new CommandError( + $"Node '{node.Name}' does not have a basic-type parameter value and cannot be used as a local variable."); + return false; + } + if (t != typeof(bool) && t != typeof(int) && t != typeof(float) && t != typeof(string)) + { + error = new CommandError( + $"Node '{node.Name}' has type '{t.FullName}' which is not a supported variable type (bool, int, float, string)."); + return false; + } + error = null; + return true; + } + + /// + /// If is IFunction<T> for a basic supported T, + /// returns T; otherwise returns null. + /// + public static Type? ExtractIFunctionInnerType(Type? type) + { + if (type is null || !type.IsGenericType) return null; + if (type.GetGenericTypeDefinition() != typeof(IFunction<>)) return null; + var inner = type.GetGenericArguments()[0]; + return (inner == typeof(bool) || inner == typeof(int) + || inner == typeof(float) || inner == typeof(string)) + ? inner : null; + } + + /// + /// Adds to . + /// The node must be inside and not already present. + /// + internal bool AddLocalVariable(Node node, [NotNullWhen(false)] out CommandError? error) + { + if (!IsValidLocalVariableNode(node, out error)) return false; + if (_localVariables.Contains(node)) + { + error = new CommandError($"Node '{node.Name}' is already a local variable of template '{Name}'."); + return false; + } + _localVariables.Add(node); + error = null; + return true; + } + + /// + /// Removes from . + /// + internal bool RemoveLocalVariable(Node node, [NotNullWhen(false)] out CommandError? error) + { + if (!_localVariables.Remove(node)) + { + error = new CommandError($"Node '{node.Name}' is not a local variable of template '{Name}'."); + return false; + } + error = null; + return true; } /// @@ -276,6 +367,19 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona if (_entryNode != null && nodeDictionary.TryGetValue(_entryNode, out int entryIdx)) writer.WriteNumber(EntryNodeProperty, entryIdx); + // Local variables – stored as an array of node indices + if (_localVariables.Count > 0) + { + writer.WritePropertyName(LocalVariablesProperty); + writer.WriteStartArray(); + foreach (var lv in _localVariables) + { + if (nodeDictionary.TryGetValue(lv, out int lvIdx)) + writer.WriteNumberValue(lvIdx); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); } @@ -316,6 +420,8 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo // Partial template reference: created as soon as we read the name so that // FunctionParameter.Load() can reference it. FunctionTemplate? partialTemplate = null; + // Deferred list of local variable indices; resolved after nodes are loaded. + List? deferredLocalVarIds = null; while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { @@ -373,6 +479,16 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo if (reader.TokenType == JsonTokenType.Number) deferredEntryNodeIndex = reader.GetInt32(); } + else if (reader.ValueTextEquals(LocalVariablesProperty)) + { + reader.Read(); // StartArray + deferredLocalVarIds = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.Number) + deferredLocalVarIds.Add(reader.GetInt32()); + } + } else { reader.Skip(); @@ -392,6 +508,16 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo template._entryNode = entryNodeCandidate; } + // Resolve local variable indices. + if (deferredLocalVarIds is not null) + { + foreach (var idx in deferredLocalVarIds) + { + if (node.TryGetValue(idx, out var lvNode)) + template._localVariables.Add(lvNode); + } + } + return true; } diff --git a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs index 7b2b40c..bb270dd 100644 --- a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs +++ b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs @@ -380,7 +380,13 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH // Now that all of the modules have been loaded we can process the scripted parameters foreach (var (toAssignTo, parameterExpression) in scriptedParameters) { - if (!toAssignTo.SetParameterExpression(modelSystem.Variables, parameterExpression, out CommandError? cmdError)) + // Nodes inside a FunctionTemplate's InternalModules have template-local + // variables that shadow the global ones; combine them (local first). + var localVars = toAssignTo.ContainedWithin?.OwningFunctionTemplate?.LocalVariables; + IList allVars = localVars is { Count: > 0 } + ? localVars.Concat(modelSystem.Variables).ToList() + : (IList)modelSystem.Variables; + if (!toAssignTo.SetParameterExpression(allVars, parameterExpression, out CommandError? cmdError)) { // TODO: Think about what to do in order to heal the model system } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs index d8b0b71..6a21786 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs @@ -29,19 +29,40 @@ public Variable(ReadOnlyMemory text, int offset) : base(text, offset) internal static Variable CreateVariableForNode(Node node, ReadOnlyMemory text, int offset) { - var parameterValue = node.ParameterValue; - if(parameterValue is null) - { - throw new CompilerException($"Unable to create a variable for node {node.Name} because it has no parameter value!", offset); + // FunctionParameter nodes expose IFunction for a basic type; handle them specially + // because they don't have a ParameterValue — their value arrives via the FunctionInstance + // hook binding at runtime. + if (node is ModelSystemConstruct.FunctionParameter fp) + { + var inner = ModelSystemConstruct.FunctionTemplate.ExtractIFunctionInnerType(fp.Type); + if (inner is null) + throw new CompilerException( + $"FunctionParameter '{node.Name}' type '{fp.Type?.FullName}' is not IFunction of a supported basic type.", + offset); + return inner.FullName switch + { + "System.Boolean" => new FunctionParameterVariable(text, offset, fp), + "System.Int32" => new FunctionParameterVariable(text, offset, fp), + "System.Single" => new FunctionParameterVariable(text, offset, fp), + "System.String" => new FunctionParameterVariable(text, offset, fp), + _ => throw new CompilerException( + $"Unsupported IFunction inner type '{inner.FullName}' for FunctionParameter '{node.Name}'.", offset) + }; + } + + var parameterValue = node.ParameterValue; + if(parameterValue is null) + { + throw new CompilerException($"Unable to create a variable for node {node.Name} because it has no parameter value!", offset); + } + return parameterValue.Type.FullName switch + { + "System.Boolean" => new BooleanVariable(text, offset, node), + "System.Int32" => new IntegerVariable(text, offset, node), + "System.Single" => new FloatVariable(text, offset, node), + "System.String" => new StringVariable(text, offset, node), + _ => throw new CompilerException($"Invalid type for a variable {parameterValue.Type.FullName} found when trying to" + + $" use {node.Name}!", offset) + }; } - return parameterValue.Type.FullName switch - { - "System.Boolean" => new BooleanVariable(text, offset, node), - "System.Int32" => new IntegerVariable(text, offset, node), - "System.Single" => new FloatVariable(text, offset, node), - "System.String" => new StringVariable(text, offset, node), - _ => throw new CompilerException($"Invalid type for a variable {parameterValue.Type.FullName} found when trying to" + - $" use {node.Name}!", offset) - }; - } -} +} \ No newline at end of file diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs new file mode 100644 index 0000000..50e0521 --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs @@ -0,0 +1,68 @@ +/* + 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; + +namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; + +/// +/// A backed by a whose type is +/// IFunction<>. +/// +/// At runtime the value is retrieved by calling on the +/// module that was bound to the parameter by the enclosing . +/// +/// +internal sealed class FunctionParameterVariable : Variable +{ + private readonly FunctionParameter _fp; + + public FunctionParameterVariable(ReadOnlyMemory text, int offset, FunctionParameter fp) + : base(text, offset) + { + _fp = fp; + } + + public override Type Type => typeof(T); + + internal override Result GetResult(IModule caller) + { + // The FunctionParameter's Module is set to the externally-bound IFunction module + // during FunctionInstance.ConstructRuntimeLinks. If it is available, invoke it. + if (_fp.Module is IFunction func) + { + var value = func.Invoke(); + return value switch + { + bool b => new BooleanResult(b) as Result, + int i => new IntegerResult(i) as Result, + float f => new FloatResult(f) as Result, + string s => new StringResult(s) as Result, + _ => null! + } ?? new ErrorResult( + $"FunctionParameter '{_fp.Name}' returned an unsupported type '{typeof(T).FullName}'.", + typeof(T)); + } + + // The module is not yet bound (design-time evaluation or un-wired slot). + return new ErrorResult( + $"FunctionParameter '{_fp.Name}' is not bound to a runtime module. " + + $"Connect an IFunction<{typeof(T).Name}> module to this hook on every FunctionInstance.", + typeof(T)); + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs new file mode 100644 index 0000000..5758bab --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs @@ -0,0 +1,317 @@ +/* + 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 Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; +using XTMF2.ModelSystemConstruct.Parameters.Compiler; +using XTMF2.RuntimeModules; + +namespace XTMF2.UnitTests.Editing; + +[TestClass] +public class TestFunctionTemplateVariables +{ + // ── Helpers ─────────────────────────────────────────────────────────── + + /// + /// Creates a node inside the given boundary, + /// sets its literal value, and returns the node. + /// + private static Node CreateLocalVar(ModelSystemSession session, User user, + Boundary boundary, string name, string value) + { + CommandError error = null; + Assert.IsTrue(session.AddNode(user, boundary, name, typeof(BasicParameter), + Rectangle.Hidden, out var node, out error), error?.Message); + Assert.IsTrue(session.SetParameterValue(user, node, value, out error), error?.Message); + return node!; + } + + // ── Add / Remove ────────────────────────────────────────────────────── + + [TestMethod] + public void AddFunctionTemplateVariable_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(AddFunctionTemplateVariable_Succeeds), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "42"); + + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables); + Assert.AreSame(node, ft.LocalVariables[0]); + }); + } + + [TestMethod] + public void AddFunctionTemplateVariable_Undo() + { + TestHelper.RunInModelSystemContext(nameof(AddFunctionTemplateVariable_Undo), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "42"); + + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables); + + Assert.IsTrue(ms.Undo(user, out error), error?.Message); + Assert.IsEmpty(ft.LocalVariables, "Undo should remove the local variable."); + }); + } + + [TestMethod] + public void AddFunctionTemplateVariable_Redo() + { + TestHelper.RunInModelSystemContext(nameof(AddFunctionTemplateVariable_Redo), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "42"); + + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.IsTrue(ms.Undo(user, out error), error?.Message); + Assert.IsEmpty(ft.LocalVariables); + + Assert.IsTrue(ms.Redo(user, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables, "Redo should restore the local variable."); + }); + } + + [TestMethod] + public void RemoveFunctionTemplateVariable_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionTemplateVariable_Succeeds), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "7"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables); + + Assert.IsTrue(ms.RemoveFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.IsEmpty(ft.LocalVariables, "LocalVariables should be empty after removal."); + }); + } + + [TestMethod] + public void RemoveFunctionTemplateVariable_Undo() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionTemplateVariable_Undo), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "7"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.IsTrue(ms.RemoveFunctionTemplateVariable(user, ft, node, out error), error?.Message); + Assert.IsEmpty(ft.LocalVariables); + + Assert.IsTrue(ms.Undo(user, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables, "Undo should restore the local variable."); + }); + } + + // ── Validation ──────────────────────────────────────────────────────── + + [TestMethod] + public void AddFunctionTemplateVariable_IncompatibleType_Fails() + { + TestHelper.RunInModelSystemContext(nameof(AddFunctionTemplateVariable_IncompatibleType_Fails), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Node with IModule type has no basic-type parameter value → cannot be a variable. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "nonVar", + typeof(IgnoreResult), Rectangle.Hidden, out var node, out error), error?.Message); + + Assert.IsFalse(ms.AddFunctionTemplateVariable(user, ft, node, out error)); + Assert.IsNotNull(error, "Should have an error for incompatible type."); + Assert.IsEmpty(ft.LocalVariables); + }); + } + + [TestMethod] + public void AddFunctionTemplateVariable_AlreadyAdded_Fails() + { + TestHelper.RunInModelSystemContext(nameof(AddFunctionTemplateVariable_AlreadyAdded_Fails), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + var node = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "1"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, node, out error), error?.Message); + + // Adding the same node a second time should fail. + Assert.IsFalse(ms.AddFunctionTemplateVariable(user, ft, node, out error)); + Assert.IsNotNull(error); + Assert.HasCount(1, ft.LocalVariables); + }); + } + + // ── FunctionParameter as local variable ─────────────────────────────── + + [TestMethod] + public void FunctionParameter_IFunctionOfBasicType_CanBeLocalVariable() + { + TestHelper.RunInModelSystemContext(nameof(FunctionParameter_IFunctionOfBasicType_CanBeLocalVariable), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // FunctionParameter of type IFunction → eligible as local variable. + Assert.IsTrue(ms.AddFunctionParameter(user, ft, "myIntParam", + typeof(IFunction), Rectangle.Hidden, out var fp, out error), error?.Message); + + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, fp, out error), error?.Message); + Assert.HasCount(1, ft.LocalVariables); + Assert.AreSame(fp, ft.LocalVariables[0]); + }); + } + + [TestMethod] + public void FunctionParameter_NonBasicType_CannotBeLocalVariable() + { + TestHelper.RunInModelSystemContext(nameof(FunctionParameter_NonBasicType_CannotBeLocalVariable), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // FunctionParameter of type IModule → NOT eligible as local variable. + Assert.IsTrue(ms.AddFunctionParameter(user, ft, "myModParam", + typeof(IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + + Assert.IsFalse(ms.AddFunctionTemplateVariable(user, ft, fp, out error)); + Assert.IsNotNull(error); + Assert.IsEmpty(ft.LocalVariables); + }); + } + + // ── Scoping: local before global ────────────────────────────────────── + + [TestMethod] + public void LocalVariable_ResolvedBeforeGlobalVariable() + { + TestHelper.RunInModelSystemContext(nameof(LocalVariable_ResolvedBeforeGlobalVariable), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Template-local variable named "shared" = 42. + var localVar = CreateLocalVar(ms, user, ft.InternalModules, "shared", "42"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, localVar, out error), error?.Message); + + // Global variable named "shared" = 999. + var globalVar = CreateLocalVar(ms, user, ms.ModelSystem.GlobalBoundary, "shared", "999"); + Assert.IsTrue(ms.AddVariable(user, globalVar, out error), error?.Message); + + // Compile an expression inside InternalModules that references "shared". + // The local variable (42) should win over the global (999). + var allVars = new System.Collections.Generic.List(); + allVars.AddRange(ft.LocalVariables); + allVars.AddRange(ms.ModelSystem.Variables); + + string compileError = null; + Assert.IsTrue(ParameterCompiler.CreateExpression(allVars, "shared", out var expr, ref compileError), + compileError); + Assert.IsTrue(ParameterCompiler.Evaluate(null!, expr, out var result, ref compileError), + compileError); + Assert.AreEqual(42, result, "Local variable should shadow the global variable."); + }); + } + + [TestMethod] + public void LocalVariable_NotVisibleOutsideTemplate() + { + TestHelper.RunInModelSystemContext(nameof(LocalVariable_NotVisibleOutsideTemplate), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Template-local variable named "secret" inside InternalModules. + var localVar = CreateLocalVar(ms, user, ft.InternalModules, "secret", "42"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, localVar, out error), error?.Message); + + // Compiling "secret" against only global variables (no local vars) should fail. + string compileError = null; + Assert.IsFalse(ParameterCompiler.CreateExpression( + ms.ModelSystem.Variables, "secret", out var discardExpr, ref compileError), + "Local variable should not be visible outside its template."); + }); + } + + // ── OwningFunctionTemplate back-reference ───────────────────────────── + + [TestMethod] + public void InternalModules_OwningFunctionTemplate_IsSet() + { + TestHelper.RunInModelSystemContext(nameof(InternalModules_OwningFunctionTemplate_IsSet), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + Assert.IsNotNull(ft.InternalModules.OwningFunctionTemplate, + "InternalModules should have a back-reference to its owning FunctionTemplate."); + Assert.AreSame(ft, ft.InternalModules.OwningFunctionTemplate); + }); + } + + [TestMethod] + public void GlobalBoundary_OwningFunctionTemplate_IsNull() + { + TestHelper.RunInModelSystemContext(nameof(GlobalBoundary_OwningFunctionTemplate_IsNull), + (user, _, ms) => + { + Assert.IsNull(ms.ModelSystem.GlobalBoundary.OwningFunctionTemplate, + "Global boundary should have no owning function template."); + }); + } +} From cd14a39781ffb48c8fe52fe60406cf64e2ecde95 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 18:44:20 -0400 Subject: [PATCH 29/41] Implement running local variables --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 3 +- .../ModelSystemConstruct/FunctionInstance.cs | 80 +++++ .../ModelSystemConstruct/FunctionTemplate.cs | 33 +- .../Parameters/Compiler/Variable.cs | 21 +- .../Compiler/Variables/BooleanVariable.cs | 7 +- .../Compiler/Variables/FloatVariable.cs | 7 +- .../Variables/FunctionParameterVariable.cs | 11 +- .../Compiler/Variables/IntegerVariable.cs | 7 +- .../Compiler/Variables/StringVariable.cs | 7 +- .../Editing/TestFunctionTemplateVariables.cs | 297 ++++++++++++++++++ 10 files changed, 457 insertions(+), 16 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 61d53ff..ef5558d 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -3772,7 +3772,8 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) return Array.Empty<(string, IBrush)>(); var knownNames = new HashSet( - _vm.ModelSystemVariables.Select(v => v.Name), + _vm.ModelSystemVariables.Select(v => v.Name) + .Concat(_vm.LocalVariables.Select(v => v.Name)), StringComparer.OrdinalIgnoreCase); var tokens = new List<(string, IBrush)>(); diff --git a/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs b/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs index fdcbfea..f264292 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs @@ -22,8 +22,10 @@ You should have received a copy of the GNU General Public License using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reflection; using System.Text.Json; using XTMF2.Editing; +using XTMF2.ModelSystemConstruct.Parameters; namespace XTMF2.ModelSystemConstruct { @@ -237,6 +239,30 @@ internal static bool Load(ref Utf8JsonReader reader, Boundary parentBoundary, /// private Dictionary? _parameterBindings; + // ── Per-instance execution context (thread-local) ───────────────── + + /// + /// Thread-local stack of objects that are currently + /// evaluating a scripted-parameter expression on this thread. Pushed by + /// and popped in the finally block. + /// + [ThreadStatic] + private static Stack? _contextStack; + + /// + /// The whose scripted-parameter expression is currently + /// being evaluated on the calling thread, or null if none. + /// + internal static FunctionInstance? Current + => _contextStack?.Count > 0 ? _contextStack.Peek() : null; + + /// + /// Returns the per-instance bound to on + /// this instance, or null if no external module was wired to that parameter. + /// + internal IModule? GetBoundModule(FunctionParameter fp) + => _parameterBindings?.TryGetValue(fp, out var m) == true ? m : null; + /// /// Records as the runtime binding for /// on this instance. @@ -271,16 +297,70 @@ internal bool ConstructRuntimeModules(XTMFRuntime runtime, ref string? error) { if (!start.ConstructModuleInstance(runtime, out var m, ref error)) return false; _runtimeModules[start] = m!; + WrapScriptedExpression(start, m!); } foreach (var node in internals.Modules) { if (!node.ConstructModuleInstance(runtime, out var m, ref error)) return false; _runtimeModules[node] = m!; + WrapScriptedExpression(node, m!); } error = null; return true; } + /// + /// If 's ParameterValue is a compiled scripted expression, + /// replaces the Expression field on the cloned with + /// a wrapper so that variable lookups inside + /// the AST during evaluation can find this instance's per-instance modules and + /// FunctionParameter bindings via . + /// + private void WrapScriptedExpression(Node node, IModule module) + { + if (node.ParameterValue is not ScriptedParameter) return; + var exprField = module.GetType().GetField("Expression", + BindingFlags.Public | BindingFlags.Instance); + if (exprField?.GetValue(module) is ParameterExpression inner) + exprField.SetValue(module, new FunctionInstanceExpression(inner, this)); + } + + /// + /// A wrapper that pushes this + /// onto for the duration + /// of , making it available to variable resolvers via + /// . + /// + private sealed class FunctionInstanceExpression : ParameterExpression + { + private readonly ParameterExpression _inner; + private readonly FunctionInstance _fi; + + internal FunctionInstanceExpression(ParameterExpression inner, FunctionInstance fi) + { + _inner = inner; + _fi = fi; + } + + public override bool IsCompatible(Type type, [NotNullWhen(false)] ref string? errorString) + => _inner.IsCompatible(type, ref errorString); + + public override object? GetValue(IModule caller, Type type, ref string? errorString) + { + (_contextStack ??= new Stack()).Push(_fi); + try { return _inner.GetValue(caller, type, ref errorString); } + finally { _contextStack.Pop(); } + } + + public override string Representation => _inner.Representation; + public override Type Type => _inner.Type; + + internal override void Save(Utf8JsonWriter writer) => _inner.Save(writer); + + internal override bool AssignToParameter(IModule module, ref string? error) + => _inner.AssignToParameter(module, ref error); + } + /// /// Wires the template's internal links using the per-instance cloned modules. /// Called by during a model-system run. diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs index 1116cba..8ad4fff 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs @@ -25,6 +25,7 @@ You should have received a copy of the GNU General Public License using System.Text.Json; using XTMF2.Editing; using XTMF2.Repository; +using XTMF2.RuntimeModules; namespace XTMF2.ModelSystemConstruct { @@ -263,7 +264,18 @@ public FunctionTemplate(string name, Boundary parent, Boundary? internalModules /// public static bool IsValidLocalVariableNode(Node node, [NotNullWhen(false)] out CommandError? error) { - var t = node is FunctionParameter fp ? ExtractIFunctionInnerType(fp.Type) : node.ParameterValue?.Type; + Type? t; + if (node is FunctionParameter fp) + { + t = ExtractIFunctionInnerType(fp.Type); + } + else + { + // Prefer ParameterValue.Type (most accurate at runtime), but fall back to the + // generic argument of the node's module type so that a freshly-created + // BasicParameter node (ParameterValue still null) is still eligible. + t = node.ParameterValue?.Type ?? ExtractBasicParameterInnerType(node.Type); + } if (t is null) { error = new CommandError( @@ -294,6 +306,25 @@ public static bool IsValidLocalVariableNode(Node node, [NotNullWhen(false)] out ? inner : null; } + /// + /// If is the closed generic form of + /// or + /// for a supported basic T, + /// returns T; otherwise returns null. + /// + private static Type? ExtractBasicParameterInnerType(Type? nodeType) + { + if (nodeType is null || !nodeType.IsGenericType) return null; + var td = nodeType.GetGenericTypeDefinition(); + if (td != typeof(RuntimeModules.BasicParameter<>) + && td != typeof(RuntimeModules.ScriptedParameter<>)) + return null; + var inner = nodeType.GetGenericArguments()[0]; + return (inner == typeof(bool) || inner == typeof(int) + || inner == typeof(float) || inner == typeof(string)) + ? inner : null; + } + /// /// Adds to . /// The node must be inside and not already present. diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs index 6a21786..b21b424 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.RuntimeModules; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -51,17 +52,29 @@ internal static Variable CreateVariableForNode(Node node, ReadOnlyMemory t } var parameterValue = node.ParameterValue; - if(parameterValue is null) + // Determine the dispatch type. Prefer ParameterValue.Type (accurate at runtime), + // but fall back to the generic argument of the node's module type so that a + // freshly-created BasicParameter node (ParameterValue still null) can still + // be used as a variable in expressions. + Type? valueType = parameterValue?.Type; + if (valueType is null && node.Type is { IsGenericType: true } nt) { - throw new CompilerException($"Unable to create a variable for node {node.Name} because it has no parameter value!", offset); + var td = nt.GetGenericTypeDefinition(); + if (td == typeof(RuntimeModules.BasicParameter<>) + || td == typeof(RuntimeModules.ScriptedParameter<>)) + valueType = nt.GetGenericArguments()[0]; } - return parameterValue.Type.FullName switch + if (valueType is null) + { + throw new CompilerException($"Unable to create a variable for node {node.Name} because it has no parameter value and its type cannot be inferred!", offset); + } + return valueType.FullName switch { "System.Boolean" => new BooleanVariable(text, offset, node), "System.Int32" => new IntegerVariable(text, offset, node), "System.Single" => new FloatVariable(text, offset, node), "System.String" => new StringVariable(text, offset, node), - _ => throw new CompilerException($"Invalid type for a variable {parameterValue.Type.FullName} found when trying to" + + _ => throw new CompilerException($"Invalid type for a variable {valueType.FullName} found when trying to" + $" use {node.Name}!", offset) }; } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/BooleanVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/BooleanVariable.cs index 325768f..ea6b8ed 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/BooleanVariable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/BooleanVariable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.ModelSystemConstruct; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -41,8 +42,10 @@ public BooleanVariable(ReadOnlyMemory text, int offset, Node backingNode) internal override Result GetResult(IModule caller) { string? error = null; - // Check to see if we're dealing with a variable that can change. - if(_backingNode.Module is ISetableValue setable) + // Prefer the per-instance module from the active FunctionInstance (if any), + // then fall back to the shared node module (global-variable case). + var backingModule = FunctionInstance.Current?.GetRuntimeModule(_backingNode) ?? _backingNode.Module; + if (backingModule is ISetableValue setable) { return new BooleanResult(setable.Get()); } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FloatVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FloatVariable.cs index 896a625..ac2fb0c 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FloatVariable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FloatVariable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.ModelSystemConstruct; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -41,8 +42,10 @@ public FloatVariable(ReadOnlyMemory text, int offset, Node backingNode) : internal override Result GetResult(IModule caller) { string? error = null; - // Check to see if we're dealing with a variable that can change. - if(_backingNode.Module is ISetableValue setable) + // Prefer the per-instance module from the active FunctionInstance (if any), + // then fall back to the shared node module (global-variable case). + var backingModule = FunctionInstance.Current?.GetRuntimeModule(_backingNode) ?? _backingNode.Module; + if (backingModule is ISetableValue setable) { return new FloatResult(setable.Get()); } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs index 50e0521..1061e8c 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.ModelSystemConstruct; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -43,8 +44,14 @@ public FunctionParameterVariable(ReadOnlyMemory text, int offset, Function internal override Result GetResult(IModule caller) { // The FunctionParameter's Module is set to the externally-bound IFunction module - // during FunctionInstance.ConstructRuntimeLinks. If it is available, invoke it. - if (_fp.Module is IFunction func) + // during FunctionInstance.ConstructRuntimeLinks. For per-instance FunctionInstances, + // the binding is stored in the active FI context rather than on the node directly. + IFunction? func = null; + if (_fp.Module is IFunction direct) + func = direct; + else if (FunctionInstance.Current?.GetBoundModule(_fp) is IFunction fiBound) + func = fiBound; + if (func is not null) { var value = func.Invoke(); return value switch diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/IntegerVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/IntegerVariable.cs index 125284e..6b2b1c2 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/IntegerVariable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/IntegerVariable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.ModelSystemConstruct; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -41,8 +42,10 @@ public IntegerVariable(ReadOnlyMemory text, int offset, Node backingNode) internal override Result GetResult(IModule caller) { string? error = null; - // Check to see if we're dealing with a variable that can change. - if(_backingNode.Module is ISetableValue setable) + // Prefer the per-instance module from the active FunctionInstance (if any), + // then fall back to the shared node module (global-variable case). + var backingModule = FunctionInstance.Current?.GetRuntimeModule(_backingNode) ?? _backingNode.Module; + if (backingModule is ISetableValue setable) { return new IntegerResult(setable.Get()); } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/StringVariable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/StringVariable.cs index 3abb165..eb24e18 100644 --- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/StringVariable.cs +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/StringVariable.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System; +using XTMF2.ModelSystemConstruct; namespace XTMF2.ModelSystemConstruct.Parameters.Compiler; @@ -41,8 +42,10 @@ public StringVariable(ReadOnlyMemory text, int offset, Node backingNode) : internal override Result GetResult(IModule caller) { string? error = null; - // Check to see if we're dealing with a variable that can change. - if(_backingNode.Module is ISetableValue setable) + // Prefer the per-instance module from the active FunctionInstance (if any), + // then fall back to the shared node module (global-variable case). + var backingModule = FunctionInstance.Current?.GetRuntimeModule(_backingNode) ?? _backingNode.Module; + if (backingModule is ISetableValue setable) { return new StringResult(setable.Get()); } diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs index 5758bab..9be30a4 100644 --- a/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs @@ -19,10 +19,14 @@ You should have received a copy of the GNU General Public License using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; using XTMF2.Editing; using XTMF2.ModelSystemConstruct; using XTMF2.ModelSystemConstruct.Parameters.Compiler; using XTMF2.RuntimeModules; +using XTMF2.UnitTests.Modules; namespace XTMF2.UnitTests.Editing; @@ -314,4 +318,297 @@ public void GlobalBoundary_OwningFunctionTemplate_IsNull() "Global boundary should have no owning function template."); }); } + + // ── ScriptedParameter references LocalVariable ──────────────────────── + + [TestMethod] + public void ScriptedParameter_CanReference_LocalVariable() + { + TestHelper.RunInModelSystemContext(nameof(ScriptedParameter_CanReference_LocalVariable), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Create BasicParameter "myVar" = 42 inside InternalModules, add as local var. + var localVar = CreateLocalVar(ms, user, ft.InternalModules, "myVar", "42"); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, localVar, out error), error?.Message); + + // Create a ScriptedParameter inside InternalModules. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", + typeof(ScriptedParameter), Rectangle.Hidden, out var scriptedNode, out error), + error?.Message); + + // The scripted parameter should be able to reference the local variable by name. + Assert.IsTrue(ms.SetParameterExpression(user, scriptedNode, "myVar", out error), + error?.Message); + Assert.IsNotNull(scriptedNode.ParameterValue, + "ScriptedParameter should have a ParameterValue after SetParameterExpression."); + }); + } + + [TestMethod] + public void ScriptedParameter_CanReference_FunctionParameterLocalVariable() + { + TestHelper.RunInModelSystemContext(nameof(ScriptedParameter_CanReference_FunctionParameterLocalVariable), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Add a FunctionParameter of type IFunction and mark it as a local variable. + Assert.IsTrue(ms.AddFunctionParameter(user, ft, "myFP", typeof(IFunction), + Rectangle.Hidden, out var fp, out error), error?.Message); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, fp, out error), error?.Message); + + // Create a ScriptedParameter inside InternalModules. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", + typeof(ScriptedParameter), Rectangle.Hidden, out var scriptedNode, out error), + error?.Message); + + // The ScriptedParameter should be able to reference the FunctionParameter local var. + Assert.IsTrue(ms.SetParameterExpression(user, scriptedNode, "myFP", out error), + error?.Message); + Assert.IsNotNull(scriptedNode.ParameterValue); + }); + } + + [TestMethod] + public void ScriptedParameter_CanReference_LocalVariable_WithoutPriorValue() + { + // Regression test: before the fix, IsValidLocalVariableNode returned false and + // Variable.CreateVariableForNode threw CompilerException when the backing node had + // ParameterValue == null (a freshly-created BasicParameter with no value ever set). + TestHelper.RunInModelSystemContext(nameof(ScriptedParameter_CanReference_LocalVariable_WithoutPriorValue), + (user, _, ms) => + { + CommandError error = null; + Assert.IsTrue(ms.AddFunctionTemplate(user, ms.ModelSystem.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // Create a BasicParameter but deliberately do NOT set a value → ParameterValue is null. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "unsetVar", typeof(BasicParameter), + Rectangle.Hidden, out var unsetNode, out error), error?.Message); + Assert.IsNull(unsetNode!.ParameterValue, "Pre-condition: no value set yet."); + + // Should still be eligible as a local variable (type is determinable from node.Type). + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, unsetNode, out error), + error?.Message); + + // A ScriptedParameter inside InternalModules should be able to reference it by name. + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", typeof(ScriptedParameter), + Rectangle.Hidden, out var scriptedNode, out error), error?.Message); + Assert.IsTrue(ms.SetParameterExpression(user, scriptedNode, "unsetVar", out error), + error?.Message); + Assert.IsNotNull(scriptedNode.ParameterValue); + }); + } + + [TestMethod] + public void LocalVariable_FunctionParameter_CanBeReferenced_AtRuntime() + { + // Regression / feature test: a FunctionParameter that is also a local variable must be + // correctly resolved at runtime through FunctionInstance.Current.GetBoundModule(). + TestHelper.RunInModelSystemContext(nameof(LocalVariable_FunctionParameter_CanBeReferenced_AtRuntime), + (user, pSession, ms) => + { + CommandError error = null; + var msys = ms.ModelSystem; + + // ── Build the FunctionTemplate ───────────────────────────────────── + Assert.IsTrue(ms.AddFunctionTemplate(user, msys.GlobalBoundary, "FPLocalVarFT", + out var ft, out error), error?.Message); + + // FunctionParameter "myFP" of type IFunction → local variable + Assert.IsTrue(ms.AddFunctionParameter(user, ft, "myFP", typeof(IFunction), + Rectangle.Hidden, out var fp, out error), error?.Message); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, fp, out error), error?.Message); + + // ScriptedParameter "result" referencing "myFP" + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", + typeof(ScriptedParameter), Rectangle.Hidden, out var resultNode, out error), + error?.Message); + Assert.IsTrue(ms.SetParameterExpression(user, resultNode, "myFP", out error), + error?.Message); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, resultNode, out error), error?.Message); + + // ── GlobalBoundary: Start → ignore → SPM → FI ───────────────────── + Assert.IsTrue(ms.AddFunctionInstance(user, msys.GlobalBoundary, ft, "fi", + Rectangle.Hidden, out var fi, out error), error?.Message); + + // Wire FI's "myFP" hook to a BasicParameter externally + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "fpSource", + typeof(BasicParameter), Rectangle.Hidden, out var fpSource, out error), error?.Message); + Assert.IsTrue(ms.SetParameterValue(user, fpSource, "FP bound value", out error), error?.Message); + + Assert.IsTrue(ms.AddModelSystemStart(user, msys.GlobalBoundary, "Start", + Rectangle.Hidden, out var start, out error), error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "AnIgnore", + typeof(IgnoreResult), Rectangle.Hidden, out var ignore, out error), error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "SPM", + typeof(SimpleParameterModule), Rectangle.Hidden, out var spm, out error), error?.Message); + + Assert.IsTrue(ms.AddLink(user, start, start.Hooks[0], ignore, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, ignore, ignore.Hooks[0], spm, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, spm, spm.Hooks[0], fi, out _, out error), error?.Message); + // Bind FI's myFP hook → fpSource + var fpHook = fi.Hooks.First(h => h.Name == "myFP"); + Assert.IsTrue(ms.AddLink(user, fi, fpHook, fpSource, out _, out error), error?.Message); + + // ── Run ─────────────────────────────────────────────────────────── + TestHelper.CreateRunClient(true, (runBus) => + { + CommandError runError = null; + bool success = false; + using var sem = new SemaphoreSlim(0); + runBus.ClientFinishedModelSystem += (_, _) => { success = true; sem.Release(); }; + runBus.ClientErrorWhenRunningModelSystem += (_, _, e, stack) => + { + runError = new CommandError(e + "\r\n" + stack); + sem.Release(); + }; + Assert.IsTrue(runBus.RunModelSystem(ms, + Path.Combine(pSession.RunsDirectory, "FPLocalVarRuntime"), + "Start", out _, out runError), runError?.Message); + Assert.IsTrue(sem.Wait(5000), "Model system did not complete in time!"); + Assert.IsTrue(success, "Model system failed: " + runError?.Message); + }); + }); + } + + [TestMethod] + public void LocalVariable_SetableParameter_CanBeReferenced_AtRuntime() + { + // Feature test: a SetableParameter local variable must be resolved via the + // per-instance cloned module (FunctionInstance.Current.GetRuntimeModule), not + // the shared template-node Module (which is null inside InternalModules). + TestHelper.RunInModelSystemContext(nameof(LocalVariable_SetableParameter_CanBeReferenced_AtRuntime), + (user, pSession, ms) => + { + CommandError error = null; + var msys = ms.ModelSystem; + + // ── FunctionTemplate ─────────────────────────────────────────────── + Assert.IsTrue(ms.AddFunctionTemplate(user, msys.GlobalBoundary, "SetableFT", + out var ft, out error), error?.Message); + + // SetableParameter "liveVar" = "Setable" → local variable + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "liveVar", + typeof(SetableParameter), Rectangle.Hidden, out var liveVarNode, out error), + error?.Message); + Assert.IsTrue(ms.SetParameterValue(user, liveVarNode, "Setable value", out error), error?.Message); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, liveVarNode, out error), error?.Message); + + // ScriptedParameter "result" → expression = "liveVar" + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", + typeof(ScriptedParameter), Rectangle.Hidden, out var resultNode, out error), + error?.Message); + Assert.IsTrue(ms.SetParameterExpression(user, resultNode, "liveVar", out error), error?.Message); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, resultNode, out error), error?.Message); + + // ── GlobalBoundary: Start → ignore → SPM → FI ───────────────────── + Assert.IsTrue(ms.AddFunctionInstance(user, msys.GlobalBoundary, ft, "fi", + Rectangle.Hidden, out var fi, out error), error?.Message); + Assert.IsTrue(ms.AddModelSystemStart(user, msys.GlobalBoundary, "Start", + Rectangle.Hidden, out var start, out error), error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "AnIgnore", + typeof(IgnoreResult), Rectangle.Hidden, out var ignore, out error), error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "SPM", + typeof(SimpleParameterModule), Rectangle.Hidden, out var spm, out error), error?.Message); + + Assert.IsTrue(ms.AddLink(user, start, start.Hooks[0], ignore, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, ignore, ignore.Hooks[0], spm, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, spm, spm.Hooks[0], fi, out _, out error), error?.Message); + + TestHelper.CreateRunClient(true, (runBus) => + { + CommandError runError = null; + bool success = false; + using var sem = new SemaphoreSlim(0); + runBus.ClientFinishedModelSystem += (_, _) => { success = true; sem.Release(); }; + runBus.ClientErrorWhenRunningModelSystem += (_, _, e, stack) => + { + runError = new CommandError(e + "\r\n" + stack); + sem.Release(); + }; + Assert.IsTrue(runBus.RunModelSystem(ms, + Path.Combine(pSession.RunsDirectory, "SetableLocalVarRuntime"), + "Start", out _, out runError), runError?.Message); + Assert.IsTrue(sem.Wait(5000), "Model system did not complete in time!"); + Assert.IsTrue(success, "Model system failed: " + runError?.Message); + }); + }); + } + + [TestMethod] + public void LocalVariable_CanBeUsed_AtRuntime() + { + // Regression test: ScriptedParameter inside a FunctionTemplate should be able + // to reference a local variable at runtime (not just at compile time). + TestHelper.RunInModelSystemContext(nameof(LocalVariable_CanBeUsed_AtRuntime), + (user, pSession, ms) => + { + CommandError error = null; + var msys = ms.ModelSystem; + + // ── Build the FunctionTemplate ───────────────────────────────────── + Assert.IsTrue(ms.AddFunctionTemplate(user, msys.GlobalBoundary, "MyFT", + out var ft, out error), error?.Message); + + // BasicParameter "helloParam" = "Hello" → register as local var + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "helloParam", + typeof(BasicParameter), Rectangle.Hidden, out var localVarNode, out error), + error?.Message); + Assert.IsTrue(ms.SetParameterValue(user, localVarNode, "Hello", out error), error?.Message); + Assert.IsTrue(ms.AddFunctionTemplateVariable(user, ft, localVarNode, out error), error?.Message); + + // ScriptedParameter "result" → expression = "helloParam" → set as EntryNode + Assert.IsTrue(ms.AddNode(user, ft.InternalModules, "result", + typeof(ScriptedParameter), Rectangle.Hidden, out var resultNode, out error), + error?.Message); + Assert.IsTrue(ms.SetParameterExpression(user, resultNode, "helloParam", out error), + error?.Message); + Assert.IsTrue(ms.SetFunctionTemplateEntryNode(user, ft, resultNode, out error), error?.Message); + + // ── Build the execution chain in GlobalBoundary ──────────────────── + // FunctionInstance "fi" exposes ScriptedParameter as IFunction. + Assert.IsTrue(ms.AddFunctionInstance(user, msys.GlobalBoundary, ft, "fi", + Rectangle.Hidden, out var fi, out error), error?.Message); + + Assert.IsTrue(ms.AddModelSystemStart(user, msys.GlobalBoundary, "Start", + Rectangle.Hidden, out var start, out error), error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "AnIgnore", + typeof(IgnoreResult), Rectangle.Hidden, out var ignore, out error), + error?.Message); + Assert.IsTrue(ms.AddNode(user, msys.GlobalBoundary, "SPM", + typeof(SimpleParameterModule), Rectangle.Hidden, out var spm, out error), + error?.Message); + + // Start → ignore → spm → fi + Assert.IsTrue(ms.AddLink(user, start, start.Hooks[0], ignore, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, ignore, ignore.Hooks[0], spm, out _, out error), error?.Message); + Assert.IsTrue(ms.AddLink(user, spm, spm.Hooks[0], fi, out _, out error), error?.Message); + + // ── Run through RunBus (exercises the full serialise/deserialise path) ─ + TestHelper.CreateRunClient(true, (runBus) => + { + CommandError runError = null; + bool success = false; + using var sem = new SemaphoreSlim(0); + runBus.ClientFinishedModelSystem += (_, _) => { success = true; sem.Release(); }; + runBus.ClientErrorWhenRunningModelSystem += (_, _, e, stack) => + { + runError = new CommandError(e + "\r\n" + stack); + sem.Release(); + }; + Assert.IsTrue(runBus.RunModelSystem(ms, + Path.Combine(pSession.RunsDirectory, "LocalVarRuntime"), + "Start", out _, out runError), runError?.Message); + Assert.IsTrue(sem.Wait(5000), "Model system did not complete in time!"); + Assert.IsTrue(success, "Model system failed: " + runError?.Message); + }); + }); + } } From c7343ee47c2faf80869ea75d8ede8da27ea3d940 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 19:19:47 -0400 Subject: [PATCH 30/41] Support adding FunctionTemplates to Multiselect --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index ef5558d..815bc68 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -2763,7 +2763,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (_editingCommentBlock is not null) CommitCommentEdit(); if (_editingNameElement is not null) CommitNameEdit(); - if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel) + if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel or FunctionTemplateViewModel) { // On the very first Ctrl+click, absorb the existing primary selection into the set. if (_multiSelection.Count == 0 && _vm.SelectedElement is not null @@ -3108,6 +3108,16 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) firstHit ??= start; } } + foreach (var ft in _vm.FunctionTemplates) + { + var ftr = new Rect(ft.X, ft.Y, ft.Width, ft.Height); + if (finalRect.Intersects(ftr)) + { + _multiSelection.Add(ft); + ft.IsSelected = true; + firstHit ??= ft; + } + } if (firstHit is not null) _vm.SelectedElement = firstHit; } From 123fc30383c538b0047686e6da7635a9c7929bdd Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 19:37:39 -0400 Subject: [PATCH 31/41] Group moving multiple modules together --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 58 ++++++++++-- .../ViewModels/CommentBlockViewModel.cs | 17 ++++ .../ViewModels/FunctionInstanceViewModel.cs | 17 ++++ .../ViewModels/FunctionParameterViewModel.cs | 15 ++++ .../ViewModels/FunctionTemplateViewModel.cs | 17 ++++ .../ViewModels/GhostNodeViewModel.cs | 17 ++++ src/XTMF2.GUI/ViewModels/NodeViewModel.cs | 18 ++++ src/XTMF2.GUI/ViewModels/StartViewModel.cs | 14 +++ src/XTMF2/Editing/CommandBatch.cs | 6 ++ src/XTMF2/Editing/CommandBuffer.cs | 10 +++ src/XTMF2/Editing/ModelSystemSession.cs | 89 +++++++++++++++++++ 11 files changed, 271 insertions(+), 7 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 815bc68..e2bb199 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -3129,20 +3129,64 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) // ── Left-button release: end element drag ───────────────────────── if (_dragging is null) return; + if (_vm is null) return; // Commit the preview position as a single session command (one undo entry). if (_multiSelection.Count > 1 && _multiSelection.Contains(_dragging)) { + // Collect the pending move rectangles from all selected elements and push them + // as one CommandBatch so the entire group drag is undone with a single Ctrl+Z. + var nodeMoves = new List<(Node, Rectangle)>(); + var commentMoves = new List<(CommentBlock, Rectangle)>(); + var templateMoves = new List<(FunctionTemplate, Rectangle)>(); + var instanceMoves = new List<(FunctionInstance, Rectangle)>(); + foreach (var el in _multiSelection) { - if (el is NodeViewModel gnvm) gnvm.CommitMove(); - else if (el is StartViewModel gsvm) gsvm.CommitMove(); - else if (el is CommentBlockViewModel gcvm) gcvm.CommitMove(); - else if (el is GhostNodeViewModel ggvm) ggvm.CommitMove(); - else if (el is FunctionTemplateViewModel gftvm) gftvm.CommitMove(); - else if (el is FunctionInstanceViewModel gfivm) gfivm.CommitMove(); - else if (el is FunctionParameterViewModel gfpvm) gfpvm.CommitMove(); + if (el is NodeViewModel gnvm) + { + var r = gnvm.TakePendingMoveRect(); + if (r.HasValue) nodeMoves.Add((gnvm.UnderlyingNode, r.Value)); + } + else if (el is StartViewModel gsvm) + { + var r = gsvm.TakePendingMoveRect(); + if (r.HasValue) nodeMoves.Add((gsvm.UnderlyingStart, r.Value)); + } + else if (el is CommentBlockViewModel gcvm) + { + var r = gcvm.TakePendingMoveRect(); + if (r.HasValue) commentMoves.Add((gcvm.UnderlyingBlock, r.Value)); + } + else if (el is GhostNodeViewModel ggvm) + { + var r = ggvm.TakePendingMoveRect(); + if (r.HasValue) nodeMoves.Add((ggvm.UnderlyingGhostNode, r.Value)); + } + else if (el is FunctionTemplateViewModel gftvm) + { + var r = gftvm.TakePendingMoveRect(); + if (r.HasValue) templateMoves.Add((gftvm.UnderlyingTemplate, r.Value)); + } + else if (el is FunctionInstanceViewModel gfivm) + { + var r = gfivm.TakePendingMoveRect(); + if (r.HasValue) instanceMoves.Add((gfivm.UnderlyingInstance, r.Value)); + } + else if (el is FunctionParameterViewModel gfpvm) + { + var r = gfpvm.TakePendingMoveRect(); + if (r.HasValue) nodeMoves.Add((gfpvm.UnderlyingParameter, r.Value)); + } } + + _vm.Session.MoveElements( + _vm.User, + nodeMoves.Count > 0 ? nodeMoves : null, + commentMoves.Count > 0 ? commentMoves : null, + templateMoves.Count > 0 ? templateMoves : null, + instanceMoves.Count > 0 ? instanceMoves : null, + out _); } else { diff --git a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs index 71ab9a1..efb6e18 100644 --- a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs @@ -127,6 +127,23 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingBlock.Location; + var w = loc.Width is 0 ? (float)DefaultWidth : loc.Width; + var h = loc.Height is 0 ? (float)DefaultHeight : loc.Height; + return new Rectangle((float)x, (float)y, w, h); + } + /// /// Move the comment block to a new canvas position, persisting the change via the session /// (supports undo/redo). diff --git a/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs index 752b4e2..a5ecab1 100644 --- a/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs @@ -160,6 +160,23 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingInstance.Location; + var w = loc.Width is 0 ? 120f : loc.Width; + var h = loc.Height is 0 ? 50f : loc.Height; + return new Rectangle((float)x, (float)y, w, h); + } + public void MoveTo(double x, double y) { var loc = UnderlyingInstance.Location; diff --git a/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs index b65d240..c4c4093 100644 --- a/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs @@ -152,6 +152,21 @@ public void CommitMove() new Rectangle((float)x, (float)y, loc.Width, loc.Height), out _); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingParameter.Location; + return new Rectangle((float)x, (float)y, loc.Width, loc.Height); + } + /// /// Updates the visual size without persisting (for resize preview). /// Call on mouse-up to persist. diff --git a/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs index 554e921..cc99968 100644 --- a/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs @@ -163,6 +163,23 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingTemplate.Location; + var w = loc.Width is 0 ? 200f : loc.Width; + var h = loc.Height is 0 ? 120f : loc.Height; + return new Rectangle((float)x, (float)y, w, h); + } + /// /// Moves the template container to a new canvas position, persisting via the session /// (supports undo/redo). diff --git a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs index 282e099..b66e614 100644 --- a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs @@ -121,6 +121,23 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingGhostNode.Location; + var w = loc.Width is 0 ? 120f : loc.Width; + var h = loc.Height is 0 ? 50f : loc.Height; + return new Rectangle((float)x, (float)y, w, h); + } + /// /// Move the ghost node to a new canvas position, persisting via the session /// (supports undo/redo). diff --git a/src/XTMF2.GUI/ViewModels/NodeViewModel.cs b/src/XTMF2.GUI/ViewModels/NodeViewModel.cs index f97255f..c9ec5e6 100644 --- a/src/XTMF2.GUI/ViewModels/NodeViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/NodeViewModel.cs @@ -168,6 +168,24 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// Use this together with for group-drag commits. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + var loc = UnderlyingNode.Location; + var w = loc.Width is 0 ? 120f : loc.Width; + var h = loc.Height is 0 ? 50f : loc.Height; + return new Rectangle((float)x, (float)y, w, h); + } + /// /// Move the node to a new canvas position, persisting the change to the /// underlying model via the session (supports undo/redo). diff --git a/src/XTMF2.GUI/ViewModels/StartViewModel.cs b/src/XTMF2.GUI/ViewModels/StartViewModel.cs index dda7e50..9aae2fc 100644 --- a/src/XTMF2.GUI/ViewModels/StartViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/StartViewModel.cs @@ -116,6 +116,20 @@ public void CommitMove() MoveTo(x, y); } + /// + /// Returns the target for the pending drag preview and clears the + /// preview state, without making a session call. Returns null when no preview is active. + /// + internal Rectangle? TakePendingMoveRect() + { + if (_previewX is null) return null; + var x = _previewX.Value; + var y = _previewY!.Value; + _previewX = null; + _previewY = null; + return new Rectangle((float)x, (float)y, (float)Diameter, (float)Diameter); + } + /// /// Move the start to a new canvas position, persisting the change to the /// underlying model via the session (supports undo/redo). diff --git a/src/XTMF2/Editing/CommandBatch.cs b/src/XTMF2/Editing/CommandBatch.cs index bf205b8..74474d5 100644 --- a/src/XTMF2/Editing/CommandBatch.cs +++ b/src/XTMF2/Editing/CommandBatch.cs @@ -32,6 +32,12 @@ public sealed class CommandBatch /// private List _Commands = new List(); + /// + /// Create an empty command batch. + /// Use to populate it before pushing to the buffer. + /// + public CommandBatch() { } + /// /// Create a command batch from a single command. /// diff --git a/src/XTMF2/Editing/CommandBuffer.cs b/src/XTMF2/Editing/CommandBuffer.cs index a00c4f8..1b746a1 100644 --- a/src/XTMF2/Editing/CommandBuffer.cs +++ b/src/XTMF2/Editing/CommandBuffer.cs @@ -89,6 +89,16 @@ internal void AddUndo(Command command) } } + /// Pushes a pre-built as a single undoable entry. + internal void AddUndo(CommandBatch batch) + { + lock (_executionLock) + { + _undo.Add(batch); + _redo.Clear(); + } + } + /// True when there is at least one undoable command. public bool CanUndo => _undo.Count > 0; diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index a7e0d9e..82feed6 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -2591,6 +2591,95 @@ public bool SetFunctionInstanceLocation(User user, FunctionInstance instance, Re } } + /// + /// Moves a heterogeneous collection of canvas elements as a single undoable operation. + /// All supplied moves are applied and recorded in one so that + /// a single undo reverses the entire group drag. + /// + /// The user issuing the command. + /// Node / Start / GhostNode / FunctionParameter moves (all are subclasses). + /// Comment-block moves. + /// Function-template moves. + /// Function-instance moves. + /// An error message if the operation fails. + /// true on success; false with an error on failure. + public bool MoveElements( + User user, + IReadOnlyList<(Node node, Rectangle newLocation)>? nodeMoves, + IReadOnlyList<(CommentBlock block, Rectangle newLocation)>? commentMoves, + IReadOnlyList<(FunctionTemplate template, Rectangle newLocation)>? templateMoves, + IReadOnlyList<(FunctionInstance instance, Rectangle newLocation)>? instanceMoves, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + error = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var batch = new CommandBatch(); + + if (nodeMoves is not null) + { + foreach (var (node, newLoc) in nodeMoves) + { + var oldLoc = node.Location; + node.SetLocation(newLoc); + var n = node; var o = oldLoc; var nl = newLoc; + batch.Add(new Command( + () => { n.SetLocation(o); return (true, null); }, + () => { n.SetLocation(nl); return (true, null); })); + } + } + + if (commentMoves is not null) + { + foreach (var (block, newLoc) in commentMoves) + { + var oldLoc = block.Location; + block.Location = newLoc; + var b = block; var o = oldLoc; var nl = newLoc; + batch.Add(new Command( + () => { b.Location = o; return (true, null); }, + () => { b.Location = nl; return (true, null); })); + } + } + + if (templateMoves is not null) + { + foreach (var (template, newLoc) in templateMoves) + { + var oldLoc = template.Location; + template.SetLocation(newLoc); + var t = template; var o = oldLoc; var nl = newLoc; + batch.Add(new Command( + () => { t.SetLocation(o); return (true, null); }, + () => { t.SetLocation(nl); return (true, null); })); + } + } + + if (instanceMoves is not null) + { + foreach (var (instance, newLoc) in instanceMoves) + { + var oldLoc = instance.Location; + instance.SetLocation(newLoc); + var inst = instance; var o = oldLoc; var nl = newLoc; + batch.Add(new Command( + () => { inst.SetLocation(o); return (true, null); }, + () => { inst.SetLocation(nl); return (true, null); })); + } + } + + Buffer.AddUndo(batch); + return true; + } + } + /// /// Create a model system session to use for a run /// From a23f84c08e1d16e7539a0ea10a68b777bbffb700 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 19:45:19 -0400 Subject: [PATCH 32/41] Fixed light theme colours for FunctionParameters and Starts --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 57 +++++++++++++++------ 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index e2bb199..8822f33 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -164,6 +164,16 @@ public sealed class ModelSystemCanvas : Control private static readonly IBrush FtTextBrushL = new SolidColorBrush(Color.FromRgb(0x2A, 0x00, 0x50)); private static readonly IBrush FtHookTextBrushL = new SolidColorBrush(Color.FromRgb(0x55, 0x11, 0xAA)); private static readonly IBrush FtCountTextBrushL = new SolidColorBrush(Color.FromArgb(0xA0, 0x66, 0x44, 0xAA)); + // Start (light) — pale amber fill, dark amber border, no neon glow + private static readonly IBrush StartFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber + private static readonly IBrush StartBorderBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x66, 0x00)); // rich amber border + private static readonly Color StartGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow + // FunctionParameter / Function Variable (light) — same amber hue family as Start + private static readonly IBrush FpFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber body + private static readonly IBrush FpHeaderFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0x9A, 0x1A)); // warm amber header + private static readonly IBrush FpBorderBrushL = new SolidColorBrush(Color.FromRgb(0xB8, 0x62, 0x00)); // dark amber border + private static readonly IBrush FpTextBrushL = new SolidColorBrush(Color.FromRgb(0x33, 0x1A, 0x00)); // near-black brown text + private static readonly Color FpGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow // Function-instance (light) private static readonly IBrush FiFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0xFF, 0xF8)); private static readonly IBrush FiHeaderFillL = new SolidColorBrush(Color.FromRgb(0x7B, 0xCF, 0xC0)); @@ -1230,22 +1240,39 @@ private void RenderFunctionParameters(DrawingContext ctx) double rh = fp.Height; var rect = new Rect(fp.X, fp.Y, rw, rh); - // Orange-red fill to visually distinguish FunctionParameter nodes. - var borderColor = fp.IsSelected ? Colors.OrangeRed : Colors.DarkOrange; - var border = new Pen(new SolidColorBrush(borderColor), NodeBorderThickness); - DrawRectGlow(ctx, rect, FiCornerRadius, fp.IsSelected ? SelectionGlowColor : Colors.OrangeRed); - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00)), border, - rect, FiCornerRadius, FiCornerRadius); + // Amber/orange fill — switches between dark and light palettes. + IBrush bodyFill, headerFill, textBrush; + Pen border; + Color glowColor; + if (_isLight) + { + bodyFill = fp.IsSelected ? new SolidColorBrush(Colors.PeachPuff) : FpFillL; + headerFill = FpHeaderFillL; + border = new Pen(fp.IsSelected ? NodeSelBrush : FpBorderBrushL, NodeBorderThickness); + textBrush = FpTextBrushL; + glowColor = fp.IsSelected ? SelectionGlowColor : FpGlowColorL; + } + else + { + bodyFill = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00)); + headerFill = new SolidColorBrush(Color.FromArgb(0xFF, 0xC0, 0x50, 0x00)); + border = new Pen(new SolidColorBrush(fp.IsSelected ? Colors.OrangeRed : Colors.DarkOrange), NodeBorderThickness); + textBrush = Brushes.White; + glowColor = fp.IsSelected ? SelectionGlowColor : Colors.OrangeRed; + } + + DrawRectGlow(ctx, rect, FiCornerRadius, glowColor); + ctx.DrawRectangle(bodyFill, border, rect, FiCornerRadius, FiCornerRadius); - // Header band in a darker orange. - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0xFF, 0xC0, 0x50, 0x00)), null, + // Header band. + ctx.DrawRectangle(headerFill, null, new Rect(fp.X, fp.Y, rw, FtHeaderHeight), FiCornerRadius, FiCornerRadius); - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00)), null, + ctx.DrawRectangle(bodyFill, null, new Rect(fp.X, fp.Y + FtHeaderHeight / 2.0, rw, rh - FtHeaderHeight / 2.0)); ctx.DrawRectangle(null, border, rect, FiCornerRadius, FiCornerRadius); - // Name label in header (no prefix so the full name fits). - var labelFtText = MakeText(fp.Name, FtNameFontSize, Brushes.White); + // Name label in header. + var labelFtText = MakeText(fp.Name, FtNameFontSize, textBrush); var lx = fp.X + 8.0; var ly = fp.Y + (FtHeaderHeight - labelFtText.Height) / 2.0; using (ctx.PushClip(new Rect(fp.X + 4, fp.Y, rw - 8, FtHeaderHeight))) @@ -1254,7 +1281,7 @@ private void RenderFunctionParameters(DrawingContext ctx) // Type name in smaller text below header. if (!string.IsNullOrEmpty(fp.TypeName)) { - var typeText = MakeText(fp.TypeName, HookFontSize, Brushes.White); + var typeText = MakeText(fp.TypeName, HookFontSize, textBrush); using (ctx.PushClip(new Rect(fp.X + 4, fp.Y + FtHeaderHeight, rw - 8, rh - FtHeaderHeight))) ctx.DrawText(typeText, new Point(lx, fp.Y + FtHeaderHeight + 4.0)); } @@ -2319,12 +2346,12 @@ private void RenderStarts(DrawingContext ctx) { foreach (var start in _vm!.Starts) { - var fill = start.IsSelected ? StartSelFill : StartFill; + var fill = start.IsSelected ? StartSelFill : (_isLight ? StartFillL : StartFill); var center = new Point(start.CenterX, start.CenterY); var r = StartViewModel.Radius; - var border = new Pen(start.IsSelected ? NodeSelBrush : (_isLight ? NodeBorderBrushL : NodeBorderBrush), NodeBorderThickness); + var border = new Pen(start.IsSelected ? NodeSelBrush : (_isLight ? StartBorderBrushL : NodeBorderBrush), NodeBorderThickness); - DrawEllipseGlow(ctx, center, r, r, start.IsSelected ? SelectionGlowColor : StartGlowColor); + DrawEllipseGlow(ctx, center, r, r, start.IsSelected ? SelectionGlowColor : (_isLight ? StartGlowColorL : StartGlowColor)); ctx.DrawEllipse(fill, border, center, r, r); // Label below the circle From 60f9e182c65d465c904800213b88d270e495de0a Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 21:53:22 -0400 Subject: [PATCH 33/41] Fix FunctionParameter Header Rendering --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 176 +----------------- .../ViewModels/FunctionParameterViewModel.cs | 3 +- 2 files changed, 6 insertions(+), 173 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 8822f33..d27c9c6 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -1262,13 +1262,12 @@ private void RenderFunctionParameters(DrawingContext ctx) } DrawRectGlow(ctx, rect, FiCornerRadius, glowColor); - ctx.DrawRectangle(bodyFill, border, rect, FiCornerRadius, FiCornerRadius); - // Header band. - ctx.DrawRectangle(headerFill, null, - new Rect(fp.X, fp.Y, rw, FtHeaderHeight), FiCornerRadius, FiCornerRadius); + // Header band: draw header fill over entire rect (preserving rounded corners), + // then overdraw the body area below the header — same pattern as FunctionTemplates. + ctx.DrawRectangle(headerFill, border, rect, FiCornerRadius, FiCornerRadius); ctx.DrawRectangle(bodyFill, null, - new Rect(fp.X, fp.Y + FtHeaderHeight / 2.0, rw, rh - FtHeaderHeight / 2.0)); + new Rect(fp.X, fp.Y + FtHeaderHeight, rw, rh - FtHeaderHeight)); ctx.DrawRectangle(null, border, rect, FiCornerRadius, FiCornerRadius); // Name label in header. @@ -1379,151 +1378,6 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) } } - /// - /// Computes the four elbow points (start, bend1, bend2, end) for a link's - /// three-segment orthogonal routing. Uses the hook anchor cache and border-clip logic. - /// - private (Point p1, Point mid1, Point mid2, Point p2) ComputeElbow(LinkViewModel link) - { - var originCenter = new Point(link.X1, link.Y1); - var destCenter = new Point(link.X2, link.Y2); - - // p1: hook anchor when origin is a Node or FunctionInstance, else border-clip. - Point p1; - bool hookOrigin = false; - if (link.Origin is NodeViewModel originNvm - && _hookAnchors.TryGetValue((originNvm, link.UnderlyingLink.OriginHook), out var hookPt)) - { - p1 = hookPt; - hookOrigin = true; - } - else if (link.Origin is FunctionInstanceViewModel fiOriginElbow - && link.UnderlyingLink.OriginHook is FunctionParameterHook fphElbow - && _fiHookAnchors.TryGetValue((fiOriginElbow, fphElbow), out var fiHookPtElbow)) - { - p1 = fiHookPtElbow; - hookOrigin = true; - } - else - { - p1 = BorderPoint(link.Origin, destCenter) ?? originCenter; - } - - // H-V-H when the link exits a hook (rightward) or horizontal span >= vertical span. - // V-H-V otherwise. - bool hvh = hookOrigin || - Math.Abs(destCenter.X - p1.X) >= Math.Abs(destCenter.Y - p1.Y); - - Point mid1, mid2, p2; - if (hvh) - { - double midX = Math.Max(p1.X + ElbowMinOffset, (p1.X + destCenter.X) / 2.0); - mid1 = new Point(midX, p1.Y); - - // Determine whether the vertical middle segment will intersect the - // destination's top or bottom border rather than a side border. - // This happens when midX falls inside the destination's horizontal span. - // In that case the old approach of using (midX, destCenter.Y) as the - // approach point is wrong: destCenter.Y equals the centre Y, so dy=0 - // in ClipLineToRect and only side borders are checked. Worse, when - // the approach point itself is inside the rect ClipLineToRect returns - // an exit intersection rather than an entry, misplacing the arrowhead. - bool midXInHSpan = false; - double borderY = 0; - if (link.Destination is NodeViewModel destNode) - { - var dRect = new Rect(destNode.X, destNode.Y, - NodeRenderWidth(destNode), NodeRenderHeight(destNode)); - midXInHSpan = midX >= dRect.X && midX <= dRect.Right; - 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; - } - else if (link.Destination is FunctionInstanceViewModel fiDestH) - { - var dRect = new Rect(fiDestH.X, fiDestH.Y, fiDestH.Width, fiDestH.Height); - midXInHSpan = midX >= dRect.X && midX <= dRect.Right; - if (midXInHSpan) - borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom; - } - - if (midXInHSpan) - { - // Vertical approach: arrow arrives straight down (or up) at the - // top (or bottom) border. Collapse mid2 onto mid1 so the second - // segment has zero length and the full arrow is the vertical shaft. - p2 = new Point(midX, borderY); - mid2 = mid1; - } - else - { - // Normal case: midX is outside the destination's horizontal span, - // so the final segment is horizontal into a side border. - var approachPt = new Point(midX, destCenter.Y); - p2 = BorderPoint(link.Destination, approachPt) ?? destCenter; - mid2 = new Point(midX, p2.Y); - } - } - else - { - double midY = (p1.Y + destCenter.Y) / 2.0; - mid1 = new Point(p1.X, midY); - - // Determine whether the horizontal middle segment will intersect the - // destination's left or right border rather than a top/bottom border. - // This happens when midY falls inside the destination's vertical span. - // In that case the approach point (destCenter.X, midY) is inside the - // destination box, which causes BorderPoint/ClipLineToRect to return - // an exit intersection rather than an entry, misplacing the arrowhead. - bool midYInVSpan = false; - double borderX = 0; - if (link.Destination is NodeViewModel destNodeV) - { - var dRect = new Rect(destNodeV.X, destNodeV.Y, - NodeRenderWidth(destNodeV), NodeRenderHeight(destNodeV)); - midYInVSpan = midY >= dRect.Y && midY <= dRect.Bottom; - 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; - } - else if (link.Destination is FunctionInstanceViewModel fiDestV) - { - var dRect = new Rect(fiDestV.X, fiDestV.Y, fiDestV.Width, fiDestV.Height); - midYInVSpan = midY >= dRect.Y && midY <= dRect.Bottom; - if (midYInVSpan) - borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right; - } - - if (midYInVSpan) - { - // Horizontal approach: arrow arrives from the left or right border. - // Collapse mid2 onto mid1 so the second segment has zero length - // and the full arrow is the horizontal shaft. - p2 = new Point(borderX, midY); - mid2 = mid1; - } - else - { - var approachPt = new Point(destCenter.X, midY); - p2 = BorderPoint(link.Destination, approachPt) ?? destCenter; - mid2 = new Point(p2.X, midY); - } - } - return (p1, mid1, mid2, p2); - } - /// /// Computes cubic Bézier control points for a direction-aware link curve. /// @@ -1637,28 +1491,6 @@ private Point BorderArrivalFrom(ICanvasElement? dest, Point borderPt) return new Point(borderPt.X - normal.X * back, borderPt.Y - normal.Y * back); } - /// - /// Computes a straight-line (p1, p2) pair for a link: - /// p1 is the hook anchor (or origin border point), and p2 is the destination - /// border point along the direct p1→destination-centre direction. - /// - private (Point p1, Point p2) ComputeDirectLine(LinkViewModel link) - { - var destCenter = new Point(link.X2, link.Y2); - - // p1: hook anchor when available, else origin border point toward dest centre. - Point p1; - if (link.Origin is NodeViewModel originNvm - && _hookAnchors.TryGetValue((originNvm, link.UnderlyingLink.OriginHook), out var hookPt)) - p1 = hookPt; - else - p1 = BorderPoint(link.Origin, destCenter) ?? new Point(link.X1, link.Y1); - - // p2: destination border point along the p1→dest direction. - var p2 = BorderPoint(link.Destination, p1) ?? destCenter; - return (p1, p2); - } - /// /// Returns the point on 's visual border that lies on /// the line between the element's centre and . diff --git a/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs index c4c4093..111d0eb 100644 --- a/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs @@ -109,7 +109,8 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) OnPropertyChanged(nameof(Height)); OnPropertyChanged(nameof(CenterX)); OnPropertyChanged(nameof(CenterY)); - break; case nameof(FunctionParameter.Type): + break; + case nameof(FunctionParameter.Type): OnPropertyChanged(nameof(TypeName)); break; } From 7386634365faac85c8cb66b41d558c63de1bfd9f Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 21:59:50 -0400 Subject: [PATCH 34/41] Made EntryPoint text more visable in light theme --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index d27c9c6..997dc83 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -212,8 +212,10 @@ public sealed class ModelSystemCanvas : Control private static readonly IBrush FiHookTextBrush = new SolidColorBrush(Color.FromRgb(0x80, 0xCB, 0xC4)); private const double FiCornerRadius = 6.0; // Entry-node highlight: gold ring + label (shown when viewing InternalModules of a FunctionTemplate) - private static readonly IBrush EntryNodeRingBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); - private static readonly IBrush EntryNodeLabelBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private static readonly IBrush EntryNodeRingBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private static readonly IBrush EntryNodeLabelBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + /// Darker amber used for the "▶ Entry Point" badge in light mode. + private static readonly IBrush EntryNodeLabelBrushL = new SolidColorBrush(Color.FromRgb(0x88, 0x55, 0x00)); private const double EntryNodeRingExtra = 3.0; // px of expansion each side beyond node rect private const double EntryNodeRingThick = 2.5; // pen width of the outer ring private const double EntryNodeLabelFontSize = 8.0; // font size for the "▶ Entry Point" badge @@ -1955,17 +1957,15 @@ private void RenderNodes(DrawingContext ctx) double headerBottom = node.Y + NodeHeaderHeight; var ft = MakeText(node.Name, NodeFontSize, _isLight ? NodeTextBrushL : NodeTextBrush); double tx = node.X + (rw - ft.Width) / 2; - // Shift name to the upper portion of the header when a badge will be drawn below it. - double ty = isEntryNode - ? node.Y + 3.0 - : node.Y + (NodeHeaderHeight - ft.Height) / 2; + double ty = node.Y + (NodeHeaderHeight - ft.Height) / 2; ctx.DrawText(ft, new Point(tx, ty)); - // ── "▶ Entry Point" badge in the lower portion of the header ────── + // ── "▶ Entry Point" badge — left-aligned in the lower portion of the header ── if (isEntryNode) { - var badge = MakeText("▶ Entry Point", EntryNodeLabelFontSize, EntryNodeLabelBrush); - double blx = node.X + (rw - badge.Width) / 2.0; + var labelBrush = _isLight ? EntryNodeLabelBrushL : EntryNodeLabelBrush; + var badge = MakeText("▶ Entry Point", EntryNodeLabelFontSize, labelBrush); + double blx = node.X + 6.0; double bly = node.Y + NodeHeaderHeight - badge.Height - 2.5; using (ctx.PushClip(new Rect(node.X + 2, node.Y, rw - 4, NodeHeaderHeight))) ctx.DrawText(badge, new Point(blx, bly)); From 285ff31880a4555f80cfed97e7b6c111096c16e7 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 22:08:30 -0400 Subject: [PATCH 35/41] Updated SaveIcon to look better --- .../Views/ModelSystemEditorView.axaml | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 86f8d2b..cdc7fc7 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -44,9 +44,9 @@ #1A0066CC #330066CC - - #FF0066CC - #FF7C3AED + + #FF0040A8 + #FF6020C0 @@ -240,17 +240,26 @@ ToolTip.Tip="{res:Localize ModelSystemEditor_SaveTooltip}" ToolTip.Placement="Pointer"> - - - + + + - - + + + + + + + StrokeThickness="1.2" StrokeLineCap="Round" StrokeJoin="Round" + Fill="Transparent"/> From 7fff4df2ce172a3b0ffb81fb011e5592cd70b7ee Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 4 Apr 2026 22:17:10 -0400 Subject: [PATCH 36/41] Add screen scrolling when dragging a canvas objects near the control's edges --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 997dc83..c7edc58 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -26,6 +26,7 @@ You should have received a copy of the GNU General Public License using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Threading; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Layout; @@ -272,6 +273,10 @@ public sealed class ModelSystemCanvas : Control private const double ScaleStep = 0.10; private const double ScaleMin = 0.10; private const double ScaleMax = 4.0; + // Auto-scroll while dragging: activate within this many screen-pixels from the viewport edge. + private const double AutoScrollZone = 48.0 * 2.0; + /// Maximum scroll delta (screen pixels) applied per pointer-move event at the very edge. + private const double AutoScrollSpeed = 14.0 / 2.0; private static readonly Typeface DefaultTypeface = new Typeface("Segoe UI, Arial, sans-serif"); @@ -519,6 +524,14 @@ public ModelSystemCanvas() // that it only appears after a minimal-movement right-click, not after // a right-drag used to create a link connection. ContextRequested += SuppressContextRequested; + + // Continuous auto-scroll timer — fires at ~60 Hz while the pointer is + // inside the scroll zone during an element drag, so the canvas keeps + // scrolling even when the mouse is stationary. + _autoScrollTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(16), + DispatcherPriority.Input, + OnAutoScrollTick); } // ── Drag state ──────────────────────────────────────────────────────── @@ -526,6 +539,13 @@ public ModelSystemCanvas() private ICanvasElement? _dragging; /// Offset from the element's top-left corner to the pointer position at drag start. private Point _dragOffset; + /// + /// Fires at ~60 Hz while the cursor is inside the auto-scroll edge zone during a drag, + /// so scrolling continues even when the mouse is stationary. + /// + private readonly DispatcherTimer _autoScrollTimer; + /// Last ScrollViewer-local cursor position, updated on every pointer-move for the timer to reuse. + private Point _lastSvPos; // ── Canvas pan state (left-drag on empty space) ─────────────────────── /// true while the user is panning by dragging empty canvas space. @@ -2374,6 +2394,65 @@ private void TryApplyZoomText() _zoomTextBox.Text = $"{(int)Math.Round(_scale * 100)}%"; } + /// + /// Scrolls the host when the pointer is within + /// pixels of any viewport edge during an element drag. + /// The scroll delta is proportional to how far inside the zone the cursor sits, + /// reaching at the very edge. + /// Also starts/stops the continuous based on whether + /// the cursor is currently inside the scroll zone. + /// + private void TryAutoScrollForDrag(Point svPos) + { + _lastSvPos = svPos; + var sv = GetScrollViewer(); + if (sv is null) return; + + double vw = sv.Bounds.Width; + double vh = sv.Bounds.Height; + + double dx = 0.0, dy = 0.0; + + if (svPos.X < AutoScrollZone) + dx = -(AutoScrollZone - svPos.X) / AutoScrollZone * AutoScrollSpeed; + else if (svPos.X > vw - AutoScrollZone) + dx = (svPos.X - (vw - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; + + if (svPos.Y < AutoScrollZone) + dy = -(AutoScrollZone - svPos.Y) / AutoScrollZone * AutoScrollSpeed; + else if (svPos.Y > vh - AutoScrollZone) + dy = (svPos.Y - (vh - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; + + if (dx != 0.0 || dy != 0.0) + { + sv.Offset = new Vector( + Math.Max(0, sv.Offset.X + dx), + Math.Max(0, sv.Offset.Y + dy)); + if (!_autoScrollTimer.IsEnabled) + _autoScrollTimer.Start(); + } + else + { + if (_autoScrollTimer.IsEnabled) + _autoScrollTimer.Stop(); + } + } + + /// + /// Called by (~60 Hz) while the cursor is stationary + /// inside the auto-scroll zone. Re-applies the scroll step so the canvas continues + /// to move even without new pointer-move events. + /// + private void OnAutoScrollTick(object? sender, EventArgs e) + { + if (_dragging is null) + { + _autoScrollTimer.Stop(); + return; + } + TryAutoScrollForDrag(_lastSvPos); + } + private void OnZoomTextBoxKeyDown(object? sender, KeyEventArgs e) { if (e.Key is Key.Enter or Key.Return) @@ -2717,6 +2796,10 @@ protected override void OnPointerMoved(PointerEventArgs e) var pos = e.GetCurrentPoint(this).Position; // screen coords var mpos = ToCanvasPos(pos); // model coords + // Capture ScrollViewer-local position now for auto-scroll use later. + var svForScroll = GetScrollViewer(); + var svPos = svForScroll is not null ? e.GetCurrentPoint(svForScroll).Position : pos; + // Right-drag: update pending link preview. if (_linkOrigin is not null) { @@ -2819,6 +2902,7 @@ protected override void OnPointerMoved(PointerEventArgs e) } InvalidateAndMeasure(); + TryAutoScrollForDrag(svPos); e.Handled = true; } @@ -3059,6 +3143,7 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) } _dragging = null; + _autoScrollTimer.Stop(); e.Pointer.Capture(null); InvalidateAndMeasure(); e.Handled = true; From 9eb6fc2abf9bcb21edeaf4947ed1fde1a0437ef0 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sun, 5 Apr 2026 07:50:20 -0400 Subject: [PATCH 37/41] Add link reordering dialog Drag and drop is not working yet. --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 47 ++- .../ViewModels/ModelSystemEditorViewModel.cs | 51 ++++ .../Views/LinkDestinationOrderDialog.axaml | 111 +++++++ .../Views/LinkDestinationOrderDialog.axaml.cs | 288 ++++++++++++++++++ 4 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 src/XTMF2.GUI/Views/LinkDestinationOrderDialog.axaml create mode 100644 src/XTMF2.GUI/Views/LinkDestinationOrderDialog.axaml.cs diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index c7edc58..c47dec8 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -42,7 +42,7 @@ namespace XTMF2.GUI.Controls; /// /// A custom Avalonia that renders the model system canvas /// using a . -/// +/// f /// Nodes are drawn as rounded rectangles, Starts as circles, and Links as lines. /// Click a node or start to select it; click empty space to deselect. /// @@ -2589,7 +2589,17 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var hookHit = HitTestHook(mpos); if (hookHit is { } hh) { - _ = _vm.CreateNodeFromHookAsync(hh.node, hh.hook, hh.anchor.X, hh.anchor.Y); + // If the hook already has a MultiLink, open the reorder dialog instead of + // creating a new auto-linked node if ctrl is down. + var hookMultiLink = _vm.Links + .Select(lvm => lvm.UnderlyingLink) + .OfType() + .FirstOrDefault(ml => ml.OriginHook == hh.hook + && ml.Origin == hh.node.UnderlyingNode); + if (hookMultiLink is not null && (e.KeyModifiers & KeyModifiers.Control) != 0) + _ = _vm.ReorderLinkDestinationsAsync(hookMultiLink); + else + _ = _vm.CreateNodeFromHookAsync(hh.node, hh.hook, hh.anchor.X, hh.anchor.Y); e.Handled = true; return; } @@ -2658,6 +2668,15 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) e.Handled = true; return; } + + // ── Double-click on a MultiLink line: open destination-order dialog ── + var dblLinkHit = HitTestLink(mpos); + if (dblLinkHit?.UnderlyingLink is MultiLink dblMultiLink) + { + _ = _vm.ReorderLinkDestinationsAsync(dblMultiLink); + e.Handled = true; + return; + } } // For right-click (link creation) we exclude comment blocks; for all other paths we include them. @@ -3236,6 +3255,20 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) _ = vm.CreateInterBoundaryLinkAsync(capturedNode, capturedHook); menu.Items.Add(interBoundaryItem); + // If this hook already has a MultiLink, offer to reorder its destinations. + var hookMultiLink = _vm.Links + .Select(lvm => lvm.UnderlyingLink) + .OfType() + .FirstOrDefault(ml => ml.OriginHook == capturedHook + && ml.Origin == capturedNode.UnderlyingNode); + if (hookMultiLink is not null) + { + var capturedHookMl = hookMultiLink; + var reorderHookItem = new MenuItem { Header = "Reorder Destinations…" }; + reorderHookItem.Click += (_, _) => _ = vm.ReorderLinkDestinationsAsync(capturedHookMl); + menu.Items.Add(reorderHookItem); + } + // Inside a function template, offer creating a FunctionParameter from a regular node hook. if (vm.IsInsideFunctionTemplate && capturedHook is not FunctionParameterHook) { @@ -3263,6 +3296,16 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) menu.Items.Add(new Separator()); } + // ── MultiLink: reorder destinations ────────────────────────────────── + if (link?.UnderlyingLink is MultiLink reorderMl) + { + var capturedReorderMl = reorderMl; + var reorderLinkItem = new MenuItem { Header = "Reorder Destinations…" }; + reorderLinkItem.Click += (_, _) => _ = vm.ReorderLinkDestinationsAsync(capturedReorderMl); + menu.Items.Add(reorderLinkItem); + menu.Items.Add(new Separator()); + } + // ── Standard Delete ─────────────────────────────────────────────── var deleteItem = new MenuItem { Header = "Delete" }; deleteItem.Click += (_, _) => diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index d29822d..89999ba 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -2464,6 +2464,57 @@ public void MoveLinkDestination(int fromIndex, int toIndex) _ = ShowError("Move Failed", error); } + /// + /// Opens the for and, + /// if the user confirms, applies the requested permutation to the session. + /// Each position swap is recorded as its own undoable command in the buffer. + /// + /// The multi-destination link whose order should be edited. + public async Task ReorderLinkDestinationsAsync(MultiLink ml) + { + if (ParentWindow is null) return; + + var names = ml.Destinations.Select(d => d.Name).ToList(); + var dlg = new LinkDestinationOrderDialog(names); + await dlg.ShowDialog(ParentWindow); + + if (dlg.WasCancelled) return; + + // Apply the permutation returned by the dialog. + // FinalOrderIndices[i] = which original index should be at position i. + ApplyLinkDestinationPermutation(ml, dlg.FinalOrderIndices); + } + + /// + /// Applies a permutation to a via sequential + /// calls, + /// keeping an internal state array in sync so index arithmetic stays correct + /// even as earlier moves shift subsequent positions. + /// + private void ApplyLinkDestinationPermutation(MultiLink ml, IReadOnlyList newOrder) + { + int n = newOrder.Count; + // current[i] holds the original index of the item currently sitting at position i. + var current = new List(Enumerable.Range(0, n)); + + for (int targetPos = 0; targetPos < n; targetPos++) + { + int desiredOriginalIdx = newOrder[targetPos]; + int currentPos = current.IndexOf(desiredOriginalIdx); + if (currentPos == targetPos) continue; + + if (!Session.MoveLinkDestination(User, ml, currentPos, targetPos, out var error) && error is not null) + { + _ = ShowError("Reorder Failed", error); + return; + } + + // Mirror the move in our local tracking array. + current.RemoveAt(currentPos); + current.Insert(targetPos, desiredOriginalIdx); + } + } + /// /// Removes a single entry from the currently selected MultiLink. /// Bound to the per-row delete button in the destination list. diff --git a/src/XTMF2.GUI/Views/LinkDestinationOrderDialog.axaml b/src/XTMF2.GUI/Views/LinkDestinationOrderDialog.axaml new file mode 100644 index 0000000..5d2f8b2 --- /dev/null +++ b/src/XTMF2.GUI/Views/LinkDestinationOrderDialog.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + private DestinationOrderItem? GetItemAtPoint(Point pointRelativeToListBox) { - var hit = DestListBox.InputHitTest(pointRelativeToListBox); - var element = hit as Visual; - while (element is not null) + // InputHitTest is unreliable when the pointer is captured by the ListBox. + // Walk all visual descendants instead and use TranslatePoint to convert each + // ListBoxItem's top-left corner into ListBox-local coordinates. + foreach (var lbi in DestListBox.GetVisualDescendants().OfType()) { - if (element is ListBoxItem { DataContext: DestinationOrderItem item }) + var topLeft = lbi.TranslatePoint(new Point(0, 0), DestListBox); + if (topLeft is not { } tl) continue; + var itemRect = new Rect(tl, new Size(lbi.Bounds.Width, lbi.Bounds.Height)); + if (itemRect.Contains(pointRelativeToListBox) + && lbi.DataContext is DestinationOrderItem item) return item; - element = element.GetVisualParent(); } return null; } diff --git a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs index 8d6fabb..ab6ebd2 100644 --- a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs @@ -46,6 +46,8 @@ public ModelSystemVariablesDialog(ModelSystemEditorViewModel vm) // Escape clears the variable filter; second Escape closes the window. FilterBox.KeyDown += OnFilterBoxKeyDown; + + Opened += (_, _) => FilterBox.Focus(); } private void OnFilterBoxKeyDown(object? sender, KeyEventArgs e) diff --git a/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml.cs b/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml.cs index 58e2ef8..363bb39 100644 --- a/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml.cs +++ b/src/XTMF2.GUI/Views/ParameterEditorDialog.axaml.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using System; using System.ComponentModel; @@ -121,6 +122,7 @@ public ParameterEditorDialog() _scriptedValidator = _ => null; InitializeComponent(); DataContext = this; + RegisterEscapeClose(); } /// @@ -143,6 +145,7 @@ public ParameterEditorDialog( InitializeComponent(); DataContext = this; + RegisterEscapeClose(); TypeLabel = $"Parameter type: {innerTypeName}"; ValueText = currentValue; @@ -151,6 +154,14 @@ public ParameterEditorDialog( Opened += (_, _) => ValueTextBox.Focus(); } + private void RegisterEscapeClose() => + AddHandler(KeyDownEvent, (_, ke) => + { + if (ke.Key != Key.Escape) return; + Cancel_Click(null, new RoutedEventArgs()); + ke.Handled = true; + }, RoutingStrategies.Tunnel); + // ── Button handlers ─────────────────────────────────────────────────────── private void OK_Click(object? sender, RoutedEventArgs e) diff --git a/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs b/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs index 8b16ecd..a7df1f9 100644 --- a/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs +++ b/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using System.Collections.Generic; using System.ComponentModel; @@ -65,6 +66,7 @@ public StartPickerDialog() InitializeComponent(); StartNames = []; DataContext = this; + RegisterEscapeClose(); } /// Window title. @@ -81,10 +83,19 @@ public StartPickerDialog(string title, string prompt, StartNames = startNames; SelectedStartName = defaultStart ?? (startNames.Count > 0 ? startNames[0] : null); DataContext = this; + RegisterEscapeClose(); Opened += (_, _) => StartComboBox.Focus(); } + private void RegisterEscapeClose() => + AddHandler(KeyDownEvent, (_, ke) => + { + if (ke.Key != Key.Escape) return; + Cancel_Click(null, new RoutedEventArgs()); + ke.Handled = true; + }, RoutingStrategies.Tunnel); + private void OK_Click(object? sender, RoutedEventArgs e) { WasCancelled = false; diff --git a/src/XTMF2.GUI/Views/TypePickerDialog.axaml.cs b/src/XTMF2.GUI/Views/TypePickerDialog.axaml.cs index 57e5a7b..8f4d289 100644 --- a/src/XTMF2.GUI/Views/TypePickerDialog.axaml.cs +++ b/src/XTMF2.GUI/Views/TypePickerDialog.axaml.cs @@ -21,6 +21,7 @@ You should have received a copy of the GNU General Public License using System.ComponentModel; using System.Linq; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using XTMF2.GUI.Controls; @@ -110,6 +111,12 @@ public TypePickerDialog(ReadOnlyObservableCollection moduleTypes, string? if (prompt is not null) _prompt = prompt; InitializeComponent(); DataContext = this; + AddHandler(KeyDownEvent, (_, ke) => + { + if (ke.Key != Key.Escape) return; + Cancel_Click(null, new RoutedEventArgs()); + ke.Handled = true; + }, RoutingStrategies.Tunnel); UpdateFilter(); // Focus the filter box and honour an initial selection once the window is shown. Opened += (_, _) => From 639f48fa267c73d3ea4b5492f2f6c41224a12e9e Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sun, 5 Apr 2026 13:05:51 -0400 Subject: [PATCH 39/41] Adding othoginal links --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 431 ++++++++++++++++-- src/XTMF2.GUI/ViewModels/LinkViewModel.cs | 16 + .../ViewModels/ModelSystemEditorViewModel.cs | 9 + src/XTMF2/Editing/ModelSystemSession.cs | 30 ++ src/XTMF2/ModelSystemConstruct/Link.cs | 28 +- src/XTMF2/ModelSystemConstruct/MultiLink.cs | 8 +- src/XTMF2/ModelSystemConstruct/SingleLink.cs | 8 +- 7 files changed, 483 insertions(+), 47 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index c47dec8..1087eac 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -1324,8 +1324,77 @@ private void RenderFunctionParameters(DrawingContext ctx) } } + /// + /// Spine-X cache: for each orthogonal that is a + /// (rendered as multiple s), stores the shared vertical-trunk X + /// so all destination branches overlap on the common horizontal exit segment. + /// Built fresh at the start of every call. + /// + private readonly Dictionary _orthogonalSpineX = new(); + + /// + /// Tracks which orthogonal multi-link groups have already had their shared trunk + /// geometry (glow + stroke) drawn during the current pass. + /// Prevents the trunk glow from being painted N times causing it to appear too bright. + /// + private readonly HashSet _orthogonalTrunkDrawn = new(ReferenceEqualityComparer.Instance); + + /// + /// For each orthogonal multi-link group, stores the full vertical extent + /// (topY, bottomY) of the shared spine — i.e. the min and max of all + /// sibling branch Y values plus the origin Y — so the trunk is drawn long enough + /// to reach every destination rather than stopping at the first one rendered. + /// + private readonly Dictionary _orthogonalTrunkRange = new(ReferenceEqualityComparer.Instance); + private void RenderLinks(DrawingContext ctx) { + // ── Precompute shared spine-X for every orthogonal multi-link group ────── + // Group all orthogonal LinkViewModels by their underlying Link object. + // Multi-destination links share the same XTMF2.Link instance, so grouping + // by reference identity collects all sibling arrows for the same hook. + _orthogonalSpineX.Clear(); + _orthogonalTrunkDrawn.Clear(); + _orthogonalTrunkRange.Clear(); + var orthogonalGroups = _vm!.Links + .Where(l => l.UnderlyingLink.IsOrthogonal && l.Destination is not null + && l.UnderlyingLink is MultiLink) + .GroupBy(l => l.UnderlyingLink, ReferenceEqualityComparer.Instance); + + foreach (var group in orthogonalGroups) + { + var siblings = group.ToList(); + if (siblings.Count < 2) continue; + + // p1 is the same for every sibling (shared origin hook). + var p1 = ComputeOrthogonalOriginPoint(siblings[0]); + + // Compute the individual spine X for each destination and take the + // maximum so that every branch can be reached from the shared trunk. + const double MinStub = 24.0; + double sharedSpineX = p1.X + MinStub; + double trunkTopY = p1.Y; + double trunkBottomY = p1.Y; + foreach (var sib in siblings) + { + if (sib.Destination is null) continue; + // Approach the destination from the right of p1 for the initial estimate. + var approachPt = new Point(p1.X + 1, p1.Y); + var p2 = OrthogonalDestBorderPoint(sib.Destination, approachPt) + ?? new Point(sib.X2, sib.Y2); + double indivMid = (p1.X + p2.X) * 0.5; + if (indivMid < p1.X + MinStub) indivMid = p1.X + MinStub; + if (indivMid > sharedSpineX) sharedSpineX = indivMid; + + // Track the full Y range so the spine covers every destination. + if (p2.Y < trunkTopY) trunkTopY = p2.Y; + if (p2.Y > trunkBottomY) trunkBottomY = p2.Y; + } + + _orthogonalSpineX[(XTMF2.Link)group.Key!] = sharedSpineX; + _orthogonalTrunkRange[(XTMF2.Link)group.Key!] = (trunkTopY, trunkBottomY); + } + foreach (var link in _vm!.Links) { // Don't render links whose destination is in a different boundary @@ -1341,34 +1410,99 @@ private void RenderLinks(DrawingContext ctx) var glowOuter = new Pen(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 8); var glowInner = new Pen(new SolidColorBrush(Color.FromArgb(0x26, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 3); - // Draw an S-shaped cubic Bézier curve. Tension adapts to the span so short - // links curve gently and long ones sweep broadly, with no elbow kinks. - var (bp1, bc1, bc2, bp2) = ComputeSCurve(link); - // Derive arrowhead direction from the destination border normal so the - // head always arrives perfectly perpendicular to the face it hits. - var arrowFrom = BorderArrivalFrom(link.Destination, bp2); - Point approachFrom = arrowFrom, arrowTip = bp2; - var shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); - - // Build the geometry once; reuse for glow and main stroke. - static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) + Point bp2, arrowFrom; + Point shaftEnd; + + if (link.UnderlyingLink.IsOrthogonal) { - var g = new StreamGeometry(); - using var gc = g.Open(); - gc.BeginFigure(p1, isFilled: false); - gc.CubicBezierTo(c1, c2, end); - gc.EndFigure(isClosed: false); - return g; + // Orthogonal (right-angle) routing: horizontal exit → vertical jog → horizontal entry. + // Use a shared spine X for multi-link groups so all branches overlap on the trunk. + _orthogonalSpineX.TryGetValue(link.UnderlyingLink, out var spineX); + bool hasSharedSpine = spineX > 0; + var pts = ComputeOrthogonalPath(link, hasSharedSpine ? spineX : (double?)null); + // pts = [p1, corner1, corner2, p2] (always 4 points) + bp2 = pts[^1]; + arrowFrom = BorderArrivalFrom(link.Destination, bp2); + shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); + + if (hasSharedSpine) + { + // For multi-link groups the trunk is identical for every sibling. + // Draw trunk glow + stroke only once to avoid stacking alpha. + if (_orthogonalTrunkDrawn.Add(link.UnderlyingLink)) + { + // Build the full-extent trunk from the precomputed range. + // The trunk is two segments that share the junction at (spineX, p1.Y): + // 1. Horizontal exit: p1 → (spineX, p1.Y) + // 2. Full vertical: (spineX, topY) → (spineX, bottomY) + // Drawing them as one polyline works when p1.Y is at one extreme; + // for the mixed case (branches above AND below) we draw two + // segments so the spine covers the complete range. + var p1Trunk = pts[0]; // hook anchor + var corner1 = pts[1]; // (spineX, p1.Y) + _orthogonalTrunkRange.TryGetValue(link.UnderlyingLink, out var range); + var spineTop = new Point(spineX, range.TopY); + var spineBot = new Point(spineX, range.BottomY); + + // Horizontal exit + vertical spine as a joined polyline. + // The vertical goes from spineTop down to spineBot; corner1 is + // somewhere along it, so we route: p1 → corner1 → spineTop + // then a separate segment corner1 → spineBot (the other direction). + // This draws the T/L shape correctly with a single extra segment. + var mainTrunkGeo = MakePolyGeo([p1Trunk, corner1, spineTop]); + var extGeo = MakeSegGeo(corner1, spineBot); + + foreach (var trunkPen in new[] { glowOuter, glowInner, pen }) + { + ctx.DrawGeometry(null, trunkPen, mainTrunkGeo); + ctx.DrawGeometry(null, trunkPen, extGeo); + } + } + + // Branch segment: corner2 → p2 (glow) and corner2 → shaftEnd (stroke). + var branchGlowGeo = MakeSegGeo(pts[^2], pts[^1]); // corner2 → bp2 + var branchShaftGeo = MakeSegGeo(pts[^2], shaftEnd); // corner2 → shaftEnd + ctx.DrawGeometry(null, glowOuter, branchGlowGeo); + ctx.DrawGeometry(null, glowInner, branchGlowGeo); + ctx.DrawGeometry(null, pen, branchShaftGeo); + } + else + { + // Single-destination orthogonal link: draw the full path normally. + var glowGeo = MakePolyGeo(pts); + var shaftGeo = ReplacePolyGeoLastPoint(pts, shaftEnd); + ctx.DrawGeometry(null, glowOuter, glowGeo); + ctx.DrawGeometry(null, glowInner, glowGeo); + ctx.DrawGeometry(null, pen, shaftGeo); + } } + else + { + // Draw an S-shaped cubic Bézier curve. Tension adapts to the span so short + // links curve gently and long ones sweep broadly, with no elbow kinks. + var (bp1, bc1, bc2, bp2c) = ComputeSCurve(link); + bp2 = bp2c; + arrowFrom = BorderArrivalFrom(link.Destination, bp2); + shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); + + // Build the geometry once; reuse for glow and main stroke. + static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) + { + var g = new StreamGeometry(); + using var gc = g.Open(); + gc.BeginFigure(p1, isFilled: false); + gc.CubicBezierTo(c1, c2, end); + gc.EndFigure(isClosed: false); + return g; + } - // Glow halos extend to bp2 so the halo wraps the arrowhead too. - var glowGeo = MakeCurveGeo(bp1, bc1, bc2, bp2); - ctx.DrawGeometry(null, glowOuter, glowGeo); - ctx.DrawGeometry(null, glowInner, glowGeo); + var glowGeo = MakeCurveGeo(bp1, bc1, bc2, bp2); + var shaftGeo = MakeCurveGeo(bp1, bc1, bc2, shaftEnd); - // Main shaft stops at shaftEnd so it doesn't overlap the filled arrowhead. - var shaftGeo = MakeCurveGeo(bp1, bc1, bc2, shaftEnd); - ctx.DrawGeometry(null, pen, shaftGeo); + ctx.DrawGeometry(null, glowOuter, glowGeo); + ctx.DrawGeometry(null, glowInner, glowGeo); + ctx.DrawGeometry(null, pen, shaftGeo); + } // For multi-link destinations draw a small 1-based index number // beside the arrowhead so the user can see the hook slot ordering. @@ -1383,12 +1517,12 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) if (idx >= 0) { var ft = MakeText((idx + 1).ToString(), LinkIndexFontSize, brush); - double dx = arrowTip.X - approachFrom.X; - double dy = arrowTip.Y - approachFrom.Y; - double dlen = Math.Sqrt(dx * dx + dy * dy); + double adx = bp2.X - arrowFrom.X; + double ady = bp2.Y - arrowFrom.Y; + double dlen = Math.Sqrt(adx * adx + ady * ady); if (dlen >= 1) { - double ux = dx / dlen, uy = dy / dlen; + double ux = adx / dlen, uy = ady / dlen; double nx = -uy, ny = ux; // 90° CCW perpendicular unit vector double offset = ft.Height * 0.5 + 3; double lx = shaftEnd.X - ft.Width * 0.5 + nx * offset; @@ -1459,6 +1593,195 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) return (p1, c1, c2, p2); } + /// + /// Computes the orthogonal-routing origin point (p1) for the given link — + /// the hook-anchor dot for nodes, or the side border midpoint for starts/other elements. + /// Extracted so both and the spine-X precomputation + /// in can call it without duplicating logic. + /// + private Point ComputeOrthogonalOriginPoint(LinkViewModel link) + { + var destCenter = new Point(link.X2, link.Y2); + + if (link.Origin is NodeViewModel originNvm + && _hookAnchors.TryGetValue((originNvm, link.UnderlyingLink.OriginHook), out var hookPt)) + return hookPt; + + if (link.Origin is FunctionInstanceViewModel fiOriginO + && link.UnderlyingLink.OriginHook is FunctionParameterHook fphO + && _fiHookAnchors.TryGetValue((fiOriginO, fphO), out var fiHookPtO)) + return fiHookPtO; + + if (link.Origin is StartViewModel startOriginO) + { + var oc = new Point(startOriginO.CenterX, startOriginO.CenterY); + var r = StartViewModel.Radius; + var dir = destCenter.X >= oc.X ? 1.0 : -1.0; + return new Point(oc.X + r * dir, oc.Y); + } + + return OrthogonalOriginBorderPoint(link.Origin, destCenter) + ?? new Point(link.X1, link.Y1); + } + + /// + /// Computes a sequence of points forming an orthogonal (right-angle) routed path + /// from the link origin to the link destination. + /// + /// The path exits the origin horizontally, jogs vertically at the horizontal midpoint, + /// then enters the destination horizontally. When the destination is to the left of + /// the origin a small stub extends rightward before doubling back, so that the exit + /// direction is always respected. + /// + /// + /// Attachment to the destination always prefers the left or right face so that the + /// final segment enters horizontally rather than from the top or bottom. + /// + /// + /// When is provided (non-null), it is used as the + /// shared vertical-trunk X for all siblings of a multi-link group, so they all + /// overlap on the horizontal exit and trunk segments and only diverge on the final + /// horizontal branch to their individual destination. + /// + /// + private Point[] ComputeOrthogonalPath(LinkViewModel link, double? sharedSpineX = null) + { + const double MinStub = 24.0; // minimum rightward stub length + + var destCenter = new Point(link.X2, link.Y2); + + // p1 — origin hook/border point. + var p1 = ComputeOrthogonalOriginPoint(link); + + // Determine the vertical trunk X. For a shared group this is supplied by + // the caller; otherwise derive it from the midpoint of this link alone. + double spineX; + if (sharedSpineX.HasValue) + { + spineX = sharedSpineX.Value; + } + else + { + // p2 needs to be estimated with the approach direction from p1's side. + var p2est = OrthogonalDestBorderPoint(link.Destination, + new Point(p1.X + 1, p1.Y)) ?? destCenter; + spineX = (p1.X + p2est.X) * 0.5; + if (spineX < p1.X + MinStub) spineX = p1.X + MinStub; + } + + // p2 — destination side border point chosen based on which side of the + // destination the trunk sits on (left face when trunk is to the left, + // right face when trunk is to the right). + var approachPt = new Point(spineX, p1.Y); + var p2 = OrthogonalDestBorderPoint(link.Destination, approachPt) ?? destCenter; + + // Three intermediate points: exit stub, corner, entry corner. + var corner1 = new Point(spineX, p1.Y); // end of horizontal exit segment + var corner2 = new Point(spineX, p2.Y); // end of vertical segment + + return [p1, corner1, corner2, p2]; + } + + /// + /// Returns a border point on the rightward (or leftward, when destination is to the left) + /// face of , at the element's vertical centre. + /// Used as the origin departure point for orthogonal links on non-hook elements. + /// Falls back to for non-rectangular origins. + /// + private Point? OrthogonalOriginBorderPoint(ICanvasElement? element, Point destCenter) + { + if (element is null) return null; + + Rect? r = element switch { + NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), + GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), + FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), + FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), + _ => (Rect?)null + }; + + if (r is { } rect) + { + double midY = rect.Y + rect.Height * 0.5; + bool goRight = destCenter.X >= rect.X + rect.Width * 0.5; + return new Point(goRight ? rect.Right : rect.X, midY); + } + + return BorderPoint(element, destCenter); + } + + /// + /// Returns the attachment point on the left or right face of + /// (at the element's vertical centre) so that orthogonal + /// links always arrive horizontally. + /// For circular elements (Start nodes) the radial border point is returned instead. + /// + private Point? OrthogonalDestBorderPoint(ICanvasElement? element, Point approachFrom) + { + if (element is null) return null; + + Rect? r = element switch { + NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), + GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), + FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), + FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), + _ => (Rect?)null + }; + + if (r is { } rect) + { + double midY = rect.Y + rect.Height * 0.5; + bool fromLeft = approachFrom.X < rect.X + rect.Width * 0.5; + return new Point(fromLeft ? rect.X : rect.Right, midY); + } + + // Circular (Start) or unknown: fall back to the standard radial border point. + return BorderPoint(element, approachFrom); + } + + /// Builds a polyline through . + private static StreamGeometry MakePolyGeo(Point[] pts) + { + var g = new StreamGeometry(); + using var gc = g.Open(); + gc.BeginFigure(pts[0], isFilled: false); + for (int i = 1; i < pts.Length; i++) + gc.LineTo(pts[i]); + gc.EndFigure(isClosed: false); + return g; + } + + /// + /// Builds a single-segment from to . + /// Used to draw an individual branch segment without allocating a full array. + /// + private static StreamGeometry MakeSegGeo(Point a, Point b) + { + var g = new StreamGeometry(); + using var gc = g.Open(); + gc.BeginFigure(a, isFilled: false); + gc.LineTo(b); + gc.EndFigure(isClosed: false); + return g; + } + + /// + /// Returns a new identical to + /// but with the final point replaced by . + /// Used to shorten the shaft so it does not overlap a filled arrowhead. + /// + private static StreamGeometry ReplacePolyGeoLastPoint(Point[] pts, Point newLastPt) + { + var g = new StreamGeometry(); + using var gc = g.Open(); + gc.BeginFigure(pts[0], isFilled: false); + for (int i = 1; i < pts.Length - 1; i++) + gc.LineTo(pts[i]); + gc.LineTo(newLastPt); + gc.EndFigure(isClosed: false); + return g; + } + /// Evaluates a cubic Bézier curve at parameter ∈ [0, 1]. private static Point SampleCubicBezier(Point p1, Point c1, Point c2, Point p2, double t) { @@ -1703,20 +2026,35 @@ private void RenderPendingLink(DrawingContext ctx) // Skip links to inlined nodes — no line is drawn for them. if (link.Destination is NodeViewModel dlNvm && dlNvm.IsInlined) continue; - // Sample the S-curve at 12 chords; any chord within tolerance is a hit. - var (hp1, hc1, hc2, hp2) = ComputeSCurve(link); - const int HitSamples = 12; - var prev = hp1; - bool curveHit = false; - for (int s = 1; s <= HitSamples && !curveHit; s++) + bool hit; + if (link.UnderlyingLink.IsOrthogonal) { - double t = s / (double)HitSamples; - var next = SampleCubicBezier(hp1, hc1, hc2, hp2, t); - if (DistToSeg(pos, prev, next) <= LinkHitTolerance) - curveHit = true; - prev = next; + // For orthogonal paths, test each straight segment. + // Use the same shared spine X that was used when rendering. + _orthogonalSpineX.TryGetValue(link.UnderlyingLink, out var spineX); + var pts = ComputeOrthogonalPath(link, spineX > 0 ? spineX : (double?)null); + hit = false; + for (int i = 1; i < pts.Length && !hit; i++) + if (DistToSeg(pos, pts[i - 1], pts[i]) <= LinkHitTolerance) + hit = true; } - if (curveHit) return link; + else + { + // Sample the S-curve at 12 chords; any chord within tolerance is a hit. + var (hp1, hc1, hc2, hp2) = ComputeSCurve(link); + const int HitSamples = 12; + var prev = hp1; + hit = false; + for (int s = 1; s <= HitSamples && !hit; s++) + { + double t = s / (double)HitSamples; + var next = SampleCubicBezier(hp1, hc1, hc2, hp2, t); + if (DistToSeg(pos, prev, next) <= LinkHitTolerance) + hit = true; + prev = next; + } + } + if (hit) return link; } return null; } @@ -3296,6 +3634,19 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) menu.Items.Add(new Separator()); } + // ── Link routing style ──────────────────────────────────────────────── + if (link is not null) + { + var capturedRoutingLink = link; + var routingHeader = link.UnderlyingLink.IsOrthogonal + ? "Switch to Curved Routing" + : "Switch to Orthogonal Routing"; + var routingItem = new MenuItem { Header = routingHeader }; + routingItem.Click += (_, _) => vm.ToggleLinkOrthogonal(capturedRoutingLink.UnderlyingLink); + menu.Items.Add(routingItem); + menu.Items.Add(new Separator()); + } + // ── MultiLink: reorder destinations ────────────────────────────────── if (link?.UnderlyingLink is MultiLink reorderMl) { diff --git a/src/XTMF2.GUI/ViewModels/LinkViewModel.cs b/src/XTMF2.GUI/ViewModels/LinkViewModel.cs index 338e4c1..65646f5 100644 --- a/src/XTMF2.GUI/ViewModels/LinkViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/LinkViewModel.cs @@ -46,6 +46,12 @@ public sealed partial class LinkViewModel : ObservableObject [ObservableProperty] private bool _isSelected; + /// + /// Whether this link should be rendered using orthogonal (right-angle) routing. + /// Mirrors and updates automatically when it changes. + /// + public bool IsOrthogonal => UnderlyingLink.IsOrthogonal; + public LinkViewModel(XTMF2.Link link, ICanvasElement origin, ICanvasElement? destination) { UnderlyingLink = link; @@ -59,6 +65,15 @@ public LinkViewModel(XTMF2.Link link, ICanvasElement origin, ICanvasElement? des origin.PropertyChanged += OnConnectedElementChanged; if (destination is not null) destination.PropertyChanged += OnConnectedElementChanged; + + // Forward link model property changes (e.g. IsOrthogonal) to the UI. + UnderlyingLink.PropertyChanged += OnUnderlyingLinkPropertyChanged; + } + + private void OnUnderlyingLinkPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(XTMF2.Link.IsOrthogonal)) + OnPropertyChanged(nameof(IsOrthogonal)); } private void OnConnectedElementChanged(object? sender, PropertyChangedEventArgs e) @@ -83,5 +98,6 @@ public void Detach() Origin.PropertyChanged -= OnConnectedElementChanged; if (Destination is not null) Destination.PropertyChanged -= OnConnectedElementChanged; + UnderlyingLink.PropertyChanged -= OnUnderlyingLinkPropertyChanged; } } diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 89999ba..1713e06 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -2532,6 +2532,15 @@ private void RemoveLinkDestinationEntry(LinkDestinationViewModel? dest) _ = ShowError("Remove Failed", error); } + /// + /// Toggles the orthogonal-routing flag on the given link (undo-able). + /// + internal void ToggleLinkOrthogonal(Link link) + { + if (!Session.SetLinkOrthogonal(User, link, !link.IsOrthogonal, out var error) && error is not null) + ShowToast(error.Message ?? "Could not change link routing.", isError: true, durationMs: 4000); + } + /// /// Sets on every VM whose /// equals . diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 82feed6..b71feeb 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -1899,6 +1899,36 @@ public bool SetLinkDisabled(User user, Link link, bool disabled, [NotNullWhen(fa } } + /// + /// Sets the orthogonal-routing flag on a link and records the change in the undo buffer. + /// + public bool SetLinkOrthogonal(User user, Link link, bool orthogonal, [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(link); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + if (link.SetOrthogonal(orthogonal, out error)) + { + Buffer.AddUndo(new Command(() => + { + return (link.SetOrthogonal(!orthogonal, out var error), error); + }, () => + { + return (link.SetOrthogonal(orthogonal, out var error), error); + })); + return true; + } + return false; + } + } + /// /// Save the model system /// diff --git a/src/XTMF2/ModelSystemConstruct/Link.cs b/src/XTMF2/ModelSystemConstruct/Link.cs index c4c0307..7b76621 100644 --- a/src/XTMF2/ModelSystemConstruct/Link.cs +++ b/src/XTMF2/ModelSystemConstruct/Link.cs @@ -39,19 +39,27 @@ public abstract class Link : INotifyPropertyChanged protected const string DestinationProperty = "Destination"; protected const string IndexProperty = "Index"; protected const string DisabledProperty = "Disabled"; + protected const string OrthogonalProperty = "Orthogonal"; public Node Origin { get; } public NodeHook OriginHook { get; } public bool IsDisabled { get; private set; } + /// + /// When true this link is rendered using orthogonal (right-angle) + /// routing instead of the default smooth cubic Bézier curve. + /// + public bool IsOrthogonal { get; private set; } + public event PropertyChangedEventHandler? PropertyChanged; - protected Link(Node origin, NodeHook hook, bool disabled) + protected Link(Node origin, NodeHook hook, bool disabled, bool orthogonal = false) { Origin = origin; OriginHook = hook; IsDisabled = disabled; + IsOrthogonal = orthogonal; } /// @@ -82,6 +90,7 @@ internal static bool Create(ModuleRepository modules, Dictionary node List? destinations = null; string? hookName = null; bool disabled = false; + bool orthogonal = false; int listIndex = 0; // read in the values while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) @@ -141,6 +150,11 @@ internal static bool Create(ModuleRepository modules, Dictionary node reader.Read(); disabled = reader.GetBoolean(); } + else if(reader.ValueTextEquals(OrthogonalProperty)) + { + reader.Read(); + orthogonal = reader.GetBoolean(); + } else { return FailWith(out link, out error, "Unknown parameter type when loading link " + reader.GetString()); @@ -168,12 +182,12 @@ internal static bool Create(ModuleRepository modules, Dictionary node } if (destination != null) { - link = new SingleLink(origin, hook, destination, disabled); + link = new SingleLink(origin, hook, destination, disabled, orthogonal); } else { // destinations can not be null if destination was. - link = new MultiLink(origin, hook, destinations!, disabled); + link = new MultiLink(origin, hook, destinations!, disabled, orthogonal); } return true; } @@ -188,6 +202,14 @@ internal bool SetDisabled(bool disabled, [NotNullWhen(false)] out CommandError? return true; } + internal bool SetOrthogonal(bool orthogonal, [NotNullWhen(false)] out CommandError? error) + { + IsOrthogonal = orthogonal; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsOrthogonal))); + error = null; + return true; + } + internal abstract bool HasDestination(Node destNode); } } diff --git a/src/XTMF2/ModelSystemConstruct/MultiLink.cs b/src/XTMF2/ModelSystemConstruct/MultiLink.cs index c85052a..8423c75 100644 --- a/src/XTMF2/ModelSystemConstruct/MultiLink.cs +++ b/src/XTMF2/ModelSystemConstruct/MultiLink.cs @@ -32,8 +32,8 @@ public sealed class MultiLink : Link private readonly ObservableCollection _Destinations; private readonly ReadOnlyObservableCollection _destinationsView; - public MultiLink(Node origin, NodeHook hook, List destinations, bool disabled) - : base(origin, hook, disabled) + public MultiLink(Node origin, NodeHook hook, List destinations, bool disabled, bool orthogonal = false) + : base(origin, hook, disabled, orthogonal) { _Destinations = new ObservableCollection(destinations); _destinationsView = new ReadOnlyObservableCollection(_Destinations); @@ -76,6 +76,10 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit { writer.WriteBoolean(DisabledProperty, true); } + if (IsOrthogonal) + { + writer.WriteBoolean(OrthogonalProperty, true); + } writer.WriteEndObject(); } diff --git a/src/XTMF2/ModelSystemConstruct/SingleLink.cs b/src/XTMF2/ModelSystemConstruct/SingleLink.cs index f31cb01..2725f98 100644 --- a/src/XTMF2/ModelSystemConstruct/SingleLink.cs +++ b/src/XTMF2/ModelSystemConstruct/SingleLink.cs @@ -29,8 +29,8 @@ public sealed class SingleLink : Link { public Node Destination { get; private set; } - public SingleLink(Node origin, NodeHook hook, Node destination, bool disabled) - : base(origin, hook, disabled) + public SingleLink(Node origin, NodeHook hook, Node destination, bool disabled, bool orthogonal = false) + : base(origin, hook, disabled, orthogonal) { Destination = destination; } @@ -53,6 +53,10 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit { writer.WriteBoolean(DisabledProperty, true); } + if (IsOrthogonal) + { + writer.WriteBoolean(OrthogonalProperty, true); + } writer.WriteEndObject(); } From 193b03097c71418f8c35564f500ed702abada833 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sun, 5 Apr 2026 13:43:16 -0400 Subject: [PATCH 40/41] Multiselect implementation for FunctionParameters --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 1087eac..dc40f9f 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -3058,7 +3058,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (_editingCommentBlock is not null) CommitCommentEdit(); if (_editingNameElement is not null) CommitNameEdit(); - if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel or FunctionTemplateViewModel) + if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel or FunctionTemplateViewModel or FunctionInstanceViewModel or FunctionParameterViewModel) { // On the very first Ctrl+click, absorb the existing primary selection into the set. if (_multiSelection.Count == 0 && _vm.SelectedElement is not null @@ -3418,6 +3418,26 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) firstHit ??= ft; } } + foreach (var fi in _vm.FunctionInstances) + { + var fir = new Rect(fi.X, fi.Y, fi.Width, fi.Height); + if (finalRect.Intersects(fir)) + { + _multiSelection.Add(fi); + fi.IsSelected = true; + firstHit ??= fi; + } + } + foreach (var fp in _vm.FunctionParameterVMs) + { + var fpr = new Rect(fp.X, fp.Y, fp.Width, fp.Height); + if (finalRect.Intersects(fpr)) + { + _multiSelection.Add(fp); + fp.IsSelected = true; + firstHit ??= fp; + } + } if (firstHit is not null) _vm.SelectedElement = firstHit; } From f1cfccca9128ee1387a0c96de7bc83f8520fdbd1 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Mon, 6 Apr 2026 10:20:35 -0400 Subject: [PATCH 41/41] Cleanup ModelSystemCanvas --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 1435 ++++++++++--------- src/XTMF2.GUI/ViewModels/ICanvasElement.cs | 27 + src/XTMF2.GUI/ViewModels/StartViewModel.cs | 21 + 3 files changed, 777 insertions(+), 706 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index dc40f9f..162fba8 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -22,7 +22,6 @@ You should have received a copy of the GNU General Public License using System.ComponentModel; using System.Globalization; using System.Linq; -using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -32,10 +31,10 @@ You should have received a copy of the GNU General Public License using Avalonia.Layout; using Avalonia.Styling; using Avalonia.VisualTree; -using XTMF2; using XTMF2.Editing; using XTMF2.GUI.ViewModels; using XTMF2.ModelSystemConstruct; +using System.Collections.ObjectModel; namespace XTMF2.GUI.Controls; @@ -50,44 +49,44 @@ namespace XTMF2.GUI.Controls; public sealed class ModelSystemCanvas : Control { // ── Brushes / pens (shared, immutable) ─────────────────────────────── - private static readonly IBrush CanvasBackground = new SolidColorBrush(Color.FromRgb(0x0E, 0x0E, 0x18)); // matches DlgBg dark token + private static readonly IBrush CanvasBackground = new SolidColorBrush(Color.FromRgb(0x0E, 0x0E, 0x18)); // matches DlgBg dark token private static readonly IBrush CanvasBackgroundLight = new SolidColorBrush(Color.FromRgb(0xF0, 0xF4, 0xF8)); - private static readonly IBrush NodeFill = new SolidColorBrush(Color.FromRgb(0x0E, 0x22, 0x38)); // deep dark blue - private static readonly IBrush NodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xCC, 0xFF)); // neon cyan - private static readonly IBrush NodeSelBrush = Brushes.DodgerBlue; - private static readonly IBrush NodeTextBrush = Brushes.White; - private static readonly IBrush StartFill = new SolidColorBrush(Color.FromRgb(0xFF, 0x77, 0x00)); // vivid orange - private static readonly IBrush StartSelFill = Brushes.DodgerBlue; - private static readonly IBrush StartTextBrush = Brushes.White; - private static readonly IBrush StartTextBrushLight = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E)); - private static readonly IBrush LinkBrush = new SolidColorBrush(Color.FromRgb(0x22, 0xBB, 0xDD)); // teal-cyan - private static readonly IBrush LinkSelBrush = Brushes.OrangeRed; - private static readonly IBrush PendingLinkBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71)); + private static readonly IBrush NodeFill = new SolidColorBrush(Color.FromRgb(0x0E, 0x22, 0x38)); // deep dark blue + private static readonly IBrush NodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xCC, 0xFF)); // neon cyan + private static readonly IBrush NodeSelBrush = Brushes.DodgerBlue; + private static readonly IBrush NodeTextBrush = Brushes.White; + private static readonly IBrush StartFill = new SolidColorBrush(Color.FromRgb(0xFF, 0x77, 0x00)); // vivid orange + private static readonly IBrush StartSelFill = Brushes.DodgerBlue; + private static readonly IBrush StartTextBrush = Brushes.White; + private static readonly IBrush StartTextBrushLight = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E)); + private static readonly IBrush LinkBrush = new SolidColorBrush(Color.FromRgb(0x22, 0xBB, 0xDD)); // teal-cyan + 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, 0x0E, 0x22, 0x38)); + private static readonly IBrush GhostNodeFill = new SolidColorBrush(Color.FromArgb(0x50, 0x0E, 0x22, 0x38)); private static readonly IBrush GhostNodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x99, 0xDD)); // steel-blue neon - private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue; - private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0); + private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue; + private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0); // Scripted-parameter syntax-highlight token colours - private static readonly IBrush ScriptVarKnownBrush = new SolidColorBrush(Color.FromRgb(0x44, 0xDD, 0x88)); // known variable → green + private static readonly IBrush ScriptVarKnownBrush = new SolidColorBrush(Color.FromRgb(0x44, 0xDD, 0x88)); // known variable → green private static readonly IBrush ScriptVarUnknownBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x44, 0x44)); // unrecognised identifier → red - private static readonly IBrush ScriptOperatorBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xBB, 0xCC)); // operators / punctuation → steel-blue - private static readonly IBrush ScriptNumberBrush = new SolidColorBrush(Color.FromRgb(0xB8, 0xD7, 0xFF)); // numeric literals → light blue - private static readonly IBrush ScriptStringBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xB8, 0x60)); // string literals → orange - private static readonly IBrush ScriptKeywordBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82)); // true / false → gold + private static readonly IBrush ScriptOperatorBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xBB, 0xCC)); // operators / punctuation → steel-blue + private static readonly IBrush ScriptNumberBrush = new SolidColorBrush(Color.FromRgb(0xB8, 0xD7, 0xFF)); // numeric literals → light blue + private static readonly IBrush ScriptStringBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xB8, 0x60)); // string literals → orange + private static readonly IBrush ScriptKeywordBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82)); // true / false → gold // Parameter value row private static readonly IBrush ParamValueTextBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82)); - private static readonly IBrush ParamValueBg = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)); + private static readonly IBrush ParamValueBg = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)); // Comment block colours (sticky-note style) - private static readonly IBrush CommentFill = new SolidColorBrush(Color.FromArgb(0xF0, 0xFF, 0xF2, 0x90)); - private static readonly IBrush CommentSelFill = new SolidColorBrush(Color.FromArgb(0xF0, 0xFF, 0xE0, 0x50)); + private static readonly IBrush CommentFill = new SolidColorBrush(Color.FromArgb(0xF0, 0xFF, 0xF2, 0x90)); + private static readonly IBrush CommentSelFill = new SolidColorBrush(Color.FromArgb(0xF0, 0xFF, 0xE0, 0x50)); private static readonly IBrush CommentBorderBrush = new SolidColorBrush(Color.FromRgb(0xDD, 0xBB, 0x00)); // warm gold - private static readonly IBrush CommentSelBorder = Brushes.DodgerBlue; - private static readonly IBrush CommentTextBrush = new SolidColorBrush(Color.FromRgb(0x22, 0x1E, 0x00)); + private static readonly IBrush CommentSelBorder = Brushes.DodgerBlue; + private static readonly IBrush CommentTextBrush = new SolidColorBrush(Color.FromRgb(0x22, 0x1E, 0x00)); /// Slightly deeper/more saturated yellow for the adhesive-tab band at the top of the sticky note. private static readonly IBrush CommentHeaderBrush = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0xD5, 0x1A)); /// Cream colour for the fold-flap back face (the bit of paper you see when the corner is turned). @@ -95,28 +94,28 @@ public sealed class ModelSystemCanvas : Control /// Semi-transparent black drop shadow for the sticky note. private static readonly IBrush CommentShadowBrush = new SolidColorBrush(Color.FromArgb(0x55, 0x00, 0x00, 0x00)); /// Faint pen for horizontal ruled lines on the note body. - private static readonly Pen CommentRulePen = new Pen(new SolidColorBrush(Color.FromArgb(0x50, 0xA0, 0x8A, 0x00)), 0.6); + private static readonly Pen CommentRulePen = new Pen(new SolidColorBrush(Color.FromArgb(0x50, 0xA0, 0x8A, 0x00)), 0.6); // Hook colours - private static readonly IBrush HookConnectedBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71)); + private static readonly IBrush HookConnectedBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71)); private static readonly IBrush HookUnconnectedBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x66, 0x77)); - private static readonly IBrush HookDividerBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)); - private static readonly IBrush HookTextConnBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xEE, 0xBB)); - private static readonly IBrush HookTextDimBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99)); + private static readonly IBrush HookDividerBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)); + private static readonly IBrush HookTextConnBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xEE, 0xBB)); + private static readonly IBrush HookTextDimBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99)); // Unsatisfied required hook (Single / AtLeastOne with no connection) - private static readonly IBrush HookUnsatisfiedBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C)); + private static readonly IBrush HookUnsatisfiedBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C)); private static readonly IBrush HookTextUnsatisfiedBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x99, 0x88)); - private static readonly IBrush HookUnsatisfiedRowBg = new SolidColorBrush(Color.FromArgb(0x30, 0xE7, 0x4C, 0x3C)); + private static readonly IBrush HookUnsatisfiedRowBg = new SolidColorBrush(Color.FromArgb(0x30, 0xE7, 0x4C, 0x3C)); // Hook toggle icon - private static readonly IBrush HookToggleBg = new SolidColorBrush(Color.FromArgb(0x60, 0x55, 0x88, 0xCC)); - private static readonly IBrush HookToggleActiveBg = new SolidColorBrush(Color.FromArgb(0x90, 0x33, 0x99, 0xFF)); - private static readonly IBrush HookToggleText = new SolidColorBrush(Color.FromRgb(0xBB, 0xCC, 0xEE)); + private static readonly IBrush HookToggleBg = new SolidColorBrush(Color.FromArgb(0x60, 0x55, 0x88, 0xCC)); + private static readonly IBrush HookToggleActiveBg = new SolidColorBrush(Color.FromArgb(0x90, 0x33, 0x99, 0xFF)); + private static readonly IBrush HookToggleText = new SolidColorBrush(Color.FromRgb(0xBB, 0xCC, 0xEE)); // Resize handle - private static readonly IBrush ResizeHandleBrush = new SolidColorBrush(Color.FromArgb(0x80, 0xAA, 0xBB, 0xCC)); + private static readonly IBrush ResizeHandleBrush = new SolidColorBrush(Color.FromArgb(0x80, 0xAA, 0xBB, 0xCC)); // Inline parameter hook row tint - private static readonly IBrush InlineParamRowBg = new SolidColorBrush(Color.FromArgb(0x28, 0xFF, 0xE0, 0x80)); + private static readonly IBrush InlineParamRowBg = new SolidColorBrush(Color.FromArgb(0x28, 0xFF, 0xE0, 0x80)); // Minimize-to-inline button on BasicParameter nodes - private static readonly IBrush MinimizeBtnBg = new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xCC, 0x55)); - private static readonly IBrush MinimizeBtnText = new SolidColorBrush(Color.FromRgb(0xCC, 0xFF, 0xAA)); + private static readonly IBrush MinimizeBtnBg = new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xCC, 0x55)); + private static readonly IBrush MinimizeBtnText = new SolidColorBrush(Color.FromRgb(0xCC, 0xFF, 0xAA)); // Rubber-band (Ctrl+drag) multi-selection rectangle private static readonly IBrush SelectionRectFill = new SolidColorBrush(Color.FromArgb(0x2E, 0x44, 0x88, 0xFF)); private static readonly DashStyle SelectionRectDash = new DashStyle([5, 4], 0); @@ -124,164 +123,161 @@ public sealed class ModelSystemCanvas : Control // ── Light-mode palette ──────────────────────────────────────────────── // Each entry below is the light-mode counterpart of a dark-mode brush above. // Render methods select between the two sets via _isLight. - private static readonly IBrush NodeFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)); // white card - private static readonly IBrush NodeBorderBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xBB)); // strong blue - private static readonly IBrush NodeTextBrushL = new SolidColorBrush(Color.FromRgb(0x0D, 0x1B, 0x2A)); // near-black - private static readonly IBrush GhostNodeFillL = new SolidColorBrush(Color.FromArgb(0x50, 0xB4, 0xC8, 0xDC)); + private static readonly IBrush NodeFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)); // white card + private static readonly IBrush NodeBorderBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xBB)); // strong blue + private static readonly IBrush NodeTextBrushL = new SolidColorBrush(Color.FromRgb(0x0D, 0x1B, 0x2A)); // near-black + private static readonly IBrush GhostNodeFillL = new SolidColorBrush(Color.FromArgb(0x50, 0xB4, 0xC8, 0xDC)); private static readonly IBrush GhostNodeBorderBrushL = new SolidColorBrush(Color.FromRgb(0x33, 0x77, 0xBB)); - private static readonly IBrush LinkBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x55, 0xAA)); - private static readonly IBrush PendingLinkBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); + private static readonly IBrush LinkBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x55, 0xAA)); + private static readonly IBrush PendingLinkBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); // Script syntax highlight (light) - private static readonly IBrush ScriptVarKnownBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); + private static readonly IBrush ScriptVarKnownBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); private static readonly IBrush ScriptVarUnknownBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x00, 0x00)); - private static readonly IBrush ScriptOperatorBrushL = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)); - private static readonly IBrush ScriptNumberBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x44, 0xAA)); - private static readonly IBrush ScriptStringBrushL = new SolidColorBrush(Color.FromRgb(0x8B, 0x45, 0x00)); - private static readonly IBrush ScriptKeywordBrushL = new SolidColorBrush(Color.FromRgb(0x7B, 0x50, 0x00)); + private static readonly IBrush ScriptOperatorBrushL = new SolidColorBrush(Color.FromRgb(0x44, 0x55, 0x66)); + private static readonly IBrush ScriptNumberBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x44, 0xAA)); + private static readonly IBrush ScriptStringBrushL = new SolidColorBrush(Color.FromRgb(0x8B, 0x45, 0x00)); + private static readonly IBrush ScriptKeywordBrushL = new SolidColorBrush(Color.FromRgb(0x7B, 0x50, 0x00)); // Parameter value row (light) - private static readonly IBrush ParamValueTextBrushL = new SolidColorBrush(Color.FromRgb(0x6B, 0x4A, 0x00)); - private static readonly IBrush ParamValueBgL = new SolidColorBrush(Color.FromArgb(0x18, 0x00, 0x00, 0x00)); + private static readonly IBrush ParamValueTextBrushL = new SolidColorBrush(Color.FromRgb(0x6B, 0x4A, 0x00)); + private static readonly IBrush ParamValueBgL = new SolidColorBrush(Color.FromArgb(0x18, 0x00, 0x00, 0x00)); // Hook row (light) - private static readonly IBrush HookConnectedBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); - private static readonly IBrush HookUnconnectedBrushL = new SolidColorBrush(Color.FromRgb(0x88, 0x99, 0xAA)); - private static readonly IBrush HookUnsatisfiedBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x00, 0x00)); - private static readonly IBrush HookDividerBrushL = new SolidColorBrush(Color.FromRgb(0xBC, 0xCD, 0xE0)); - private static readonly IBrush HookTextConnBrushL = new SolidColorBrush(Color.FromRgb(0x0D, 0x5A, 0x28)); - private static readonly IBrush HookTextDimBrushL = new SolidColorBrush(Color.FromRgb(0x5A, 0x70, 0x80)); + private static readonly IBrush HookConnectedBrushL = new SolidColorBrush(Color.FromRgb(0x1A, 0x7A, 0x40)); + private static readonly IBrush HookUnconnectedBrushL = new SolidColorBrush(Color.FromRgb(0x88, 0x99, 0xAA)); + private static readonly IBrush HookUnsatisfiedBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x00, 0x00)); + private static readonly IBrush HookDividerBrushL = new SolidColorBrush(Color.FromRgb(0xBC, 0xCD, 0xE0)); + private static readonly IBrush HookTextConnBrushL = new SolidColorBrush(Color.FromRgb(0x0D, 0x5A, 0x28)); + private static readonly IBrush HookTextDimBrushL = new SolidColorBrush(Color.FromRgb(0x5A, 0x70, 0x80)); private static readonly IBrush HookTextUnsatisfiedBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x00, 0x00)); // Hook toggle icon (light) - private static readonly IBrush HookToggleBgL = new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xAA, 0xCC)); - private static readonly IBrush HookToggleActiveBgL = new SolidColorBrush(Color.FromArgb(0xA0, 0x11, 0x66, 0xFF)); - private static readonly IBrush HookToggleTextL = new SolidColorBrush(Color.FromRgb(0x22, 0x44, 0x66)); + private static readonly IBrush HookToggleBgL = new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xAA, 0xCC)); + private static readonly IBrush HookToggleActiveBgL = new SolidColorBrush(Color.FromArgb(0xA0, 0x11, 0x66, 0xFF)); + private static readonly IBrush HookToggleTextL = new SolidColorBrush(Color.FromRgb(0x22, 0x44, 0x66)); // Resize handle (light) - private static readonly IBrush ResizeHandleBrushL = new SolidColorBrush(Color.FromArgb(0x80, 0x77, 0x88, 0xAA)); + private static readonly IBrush ResizeHandleBrushL = new SolidColorBrush(Color.FromArgb(0x80, 0x77, 0x88, 0xAA)); // Minimize-to-inline button (light) - private static readonly IBrush MinimizeBtnBgL = new SolidColorBrush(Color.FromArgb(0x60, 0x44, 0x88, 0x22)); - private static readonly IBrush MinimizeBtnTextL = new SolidColorBrush(Color.FromRgb(0x22, 0x55, 0x00)); + private static readonly IBrush MinimizeBtnBgL = new SolidColorBrush(Color.FromArgb(0x60, 0x44, 0x88, 0x22)); + private static readonly IBrush MinimizeBtnTextL = new SolidColorBrush(Color.FromRgb(0x22, 0x55, 0x00)); // Function-template (light) - private static readonly IBrush FtFillL = new SolidColorBrush(Color.FromRgb(0xF6, 0xEE, 0xFF)); - private static readonly IBrush FtHeaderFillL = new SolidColorBrush(Color.FromRgb(0xC8, 0xA0, 0xE0)); - private static readonly IBrush FtBorderBrushL = new SolidColorBrush(Color.FromRgb(0x77, 0x22, 0xCC)); - private static readonly IBrush FtTextBrushL = new SolidColorBrush(Color.FromRgb(0x2A, 0x00, 0x50)); + private static readonly IBrush FtFillL = new SolidColorBrush(Color.FromRgb(0xF6, 0xEE, 0xFF)); + private static readonly IBrush FtHeaderFillL = new SolidColorBrush(Color.FromRgb(0xC8, 0xA0, 0xE0)); + private static readonly IBrush FtBorderBrushL = new SolidColorBrush(Color.FromRgb(0x77, 0x22, 0xCC)); + private static readonly IBrush FtTextBrushL = new SolidColorBrush(Color.FromRgb(0x2A, 0x00, 0x50)); private static readonly IBrush FtHookTextBrushL = new SolidColorBrush(Color.FromRgb(0x55, 0x11, 0xAA)); private static readonly IBrush FtCountTextBrushL = new SolidColorBrush(Color.FromArgb(0xA0, 0x66, 0x44, 0xAA)); // Start (light) — pale amber fill, dark amber border, no neon glow - private static readonly IBrush StartFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber + private static readonly IBrush StartFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber private static readonly IBrush StartBorderBrushL = new SolidColorBrush(Color.FromRgb(0xCC, 0x66, 0x00)); // rich amber border - private static readonly Color StartGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow + private static readonly Color StartGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow // FunctionParameter / Function Variable (light) — same amber hue family as Start - private static readonly IBrush FpFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber body - private static readonly IBrush FpHeaderFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0x9A, 0x1A)); // warm amber header + private static readonly IBrush FpFillL = new SolidColorBrush(Color.FromRgb(0xFF, 0xF0, 0xD9)); // pale cream-amber body + private static readonly IBrush FpHeaderFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0x9A, 0x1A)); // warm amber header private static readonly IBrush FpBorderBrushL = new SolidColorBrush(Color.FromRgb(0xB8, 0x62, 0x00)); // dark amber border - private static readonly IBrush FpTextBrushL = new SolidColorBrush(Color.FromRgb(0x33, 0x1A, 0x00)); // near-black brown text - private static readonly Color FpGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow + private static readonly IBrush FpTextBrushL = new SolidColorBrush(Color.FromRgb(0x33, 0x1A, 0x00)); // near-black brown text + private static readonly Color FpGlowColorL = Color.FromRgb(0xBB, 0x55, 0x00); // subdued amber glow // Function-instance (light) - private static readonly IBrush FiFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0xFF, 0xF8)); - private static readonly IBrush FiHeaderFillL = new SolidColorBrush(Color.FromRgb(0x7B, 0xCF, 0xC0)); - private static readonly IBrush FiBorderBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x7A, 0x6B)); + private static readonly IBrush FiFillL = new SolidColorBrush(Color.FromRgb(0xE8, 0xFF, 0xF8)); + private static readonly IBrush FiHeaderFillL = new SolidColorBrush(Color.FromRgb(0x7B, 0xCF, 0xC0)); + private static readonly IBrush FiBorderBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x7A, 0x6B)); private static readonly IBrush FiSelBorderBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x55, 0x48)); - private static readonly IBrush FiTextBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x33, 0x28)); - private static readonly IBrush FiSubTextBrushL = new SolidColorBrush(Color.FromArgb(0xC0, 0x3A, 0x55, 0x50)); - private static readonly IBrush FiHookTextBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x57, 0x4E)); + private static readonly IBrush FiTextBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x33, 0x28)); + private static readonly IBrush FiSubTextBrushL = new SolidColorBrush(Color.FromArgb(0xC0, 0x3A, 0x55, 0x50)); + private static readonly IBrush FiHookTextBrushL = new SolidColorBrush(Color.FromRgb(0x00, 0x57, 0x4E)); // Glow colours (light mode — more subdued than dark-mode neons) - private static readonly Color NodeGlowColorL = Color.FromRgb(0x00, 0x66, 0xBB); - private static readonly Color FtGlowColorL = Color.FromRgb(0x88, 0x22, 0xCC); - private static readonly Color FiGlowColorL = Color.FromRgb(0x00, 0x7A, 0x6B); + private static readonly Color NodeGlowColorL = Color.FromRgb(0x00, 0x66, 0xBB); + private static readonly Color FtGlowColorL = Color.FromRgb(0x88, 0x22, 0xCC); + private static readonly Color FiGlowColorL = Color.FromRgb(0x00, 0x7A, 0x6B); private static readonly Color GhostGlowColorL = Color.FromRgb(0x33, 0x77, 0xBB); - private static readonly Color LinkGlowColorL = Color.FromRgb(0x00, 0x66, 0xBB); + private static readonly Color LinkGlowColorL = Color.FromRgb(0x00, 0x66, 0xBB); // Function-template container box - private static readonly IBrush FtFill = new SolidColorBrush(Color.FromRgb(0x20, 0x12, 0x38)); - private static readonly IBrush FtHeaderFill = new SolidColorBrush(Color.FromRgb(0x4A, 0x28, 0x6E)); - private static readonly IBrush FtBorderBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0x55, 0xFF)); // vivid purple - private static readonly IBrush FtSelBorderBrush = Brushes.DodgerBlue; - private static readonly IBrush FtTextBrush = new SolidColorBrush(Color.FromRgb(0xDD, 0xCC, 0xFF)); - private static readonly IBrush FtHookTextBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xAA, 0xFF)); - private static readonly IBrush FtCountTextBrush = new SolidColorBrush(Color.FromArgb(0x90, 0xCC, 0xAA, 0xFF)); - private static readonly DashStyle FtBorderDash = new DashStyle([8, 3], 0); - private const double FtHeaderHeight = 28.0; + private static readonly IBrush FtFill = new SolidColorBrush(Color.FromRgb(0x20, 0x12, 0x38)); + private static readonly IBrush FtHeaderFill = new SolidColorBrush(Color.FromRgb(0x4A, 0x28, 0x6E)); + private static readonly IBrush FtBorderBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0x55, 0xFF)); // vivid purple + private static readonly IBrush FtSelBorderBrush = Brushes.DodgerBlue; + private static readonly IBrush FtTextBrush = new SolidColorBrush(Color.FromRgb(0xDD, 0xCC, 0xFF)); + private static readonly IBrush FtHookTextBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xAA, 0xFF)); + private static readonly IBrush FtCountTextBrush = new SolidColorBrush(Color.FromArgb(0x90, 0xCC, 0xAA, 0xFF)); + private static readonly DashStyle FtBorderDash = new DashStyle([8, 3], 0); + private const double FtHeaderHeight = 28.0; private const double FtHookRowHeight = 16.0; - private const double FtNameFontSize = 11.0; - private const double FtCornerRadius = 6.0; + private const double FtNameFontSize = 11.0; + private const double FtCornerRadius = 6.0; // Function-instance box (teal/green palette, solid border to distinguish from template) - private static readonly IBrush FiFill = new SolidColorBrush(Color.FromRgb(0x07, 0x24, 0x24)); - private static readonly IBrush FiHeaderFill = new SolidColorBrush(Color.FromRgb(0x0E, 0x4A, 0x44)); - private static readonly IBrush FiBorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xFF, 0xCC)); // vivid mint-teal + private static readonly IBrush FiFill = new SolidColorBrush(Color.FromRgb(0x07, 0x24, 0x24)); + private static readonly IBrush FiHeaderFill = new SolidColorBrush(Color.FromRgb(0x0E, 0x4A, 0x44)); + private static readonly IBrush FiBorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xFF, 0xCC)); // vivid mint-teal private static readonly IBrush FiSelBorderBrush = new SolidColorBrush(Color.FromRgb(0x24, 0xCF, 0xCA)); - private static readonly IBrush FiTextBrush = new SolidColorBrush(Color.FromRgb(0xB2, 0xFF, 0xF0)); - private static readonly IBrush FiSubTextBrush = new SolidColorBrush(Color.FromArgb(0xB0, 0x80, 0xE8, 0xD0)); - private static readonly IBrush FiHookTextBrush = new SolidColorBrush(Color.FromRgb(0x80, 0xCB, 0xC4)); + private static readonly IBrush FiTextBrush = new SolidColorBrush(Color.FromRgb(0xB2, 0xFF, 0xF0)); + private static readonly IBrush FiSubTextBrush = new SolidColorBrush(Color.FromArgb(0xB0, 0x80, 0xE8, 0xD0)); + private static readonly IBrush FiHookTextBrush = new SolidColorBrush(Color.FromRgb(0x80, 0xCB, 0xC4)); private const double FiCornerRadius = 6.0; // Entry-node highlight: gold ring + label (shown when viewing InternalModules of a FunctionTemplate) - private static readonly IBrush EntryNodeRingBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); - private static readonly IBrush EntryNodeLabelBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private static readonly IBrush EntryNodeRingBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); + private static readonly IBrush EntryNodeLabelBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD0, 0x00)); /// Darker amber used for the "▶ Entry Point" badge in light mode. private static readonly IBrush EntryNodeLabelBrushL = new SolidColorBrush(Color.FromRgb(0x88, 0x55, 0x00)); - private const double EntryNodeRingExtra = 3.0; // px of expansion each side beyond node rect - private const double EntryNodeRingThick = 2.5; // pen width of the outer ring + private const double EntryNodeRingExtra = 3.0; // px of expansion each side beyond node rect + private const double EntryNodeRingThick = 2.5; // pen width of the outer ring private const double EntryNodeLabelFontSize = 8.0; // font size for the "▶ Entry Point" badge // Graph-paper background grid - private static readonly Pen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(0x38, 0x55, 0x77, 0xAA)), 0.5); + private static readonly Pen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(0x38, 0x55, 0x77, 0xAA)), 0.5); private static readonly Pen GridPenLight = new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xAA, 0xCC)), 0.5); /// 1 cm expressed in Avalonia logical pixels (96 DPI basis). private const double GridSpacingDip = 96.0 / 2.54; // Neon glow colours — applied as outward-expanding alpha halos in DrawRectGlow / DrawEllipseGlow. - private static readonly Color NodeGlowColor = Color.FromRgb(0x00, 0xCC, 0xFF); // electric cyan - private static readonly Color StartGlowColor = Color.FromRgb(0xFF, 0x88, 0x00); // vivid orange - private static readonly Color FtGlowColor = Color.FromRgb(0xAA, 0x44, 0xFF); // neon purple - private static readonly Color FiGlowColor = Color.FromRgb(0x00, 0xFF, 0xCC); // mint-teal - private static readonly Color CommentGlowColor = Color.FromRgb(0xFF, 0xD7, 0x00); // gold - private static readonly Color GhostGlowColor = Color.FromRgb(0x44, 0x99, 0xDD); // steel-blue - private static readonly Color LinkGlowColor = Color.FromRgb(0x00, 0xCC, 0xFF); // cyan - private static readonly Color LinkSelGlowColor = Color.FromRgb(0xFF, 0x55, 0x00); // orange-red + private static readonly Color NodeGlowColor = Color.FromRgb(0x00, 0xCC, 0xFF); // electric cyan + private static readonly Color StartGlowColor = Color.FromRgb(0xFF, 0x88, 0x00); // vivid orange + private static readonly Color FtGlowColor = Color.FromRgb(0xAA, 0x44, 0xFF); // neon purple + private static readonly Color FiGlowColor = Color.FromRgb(0x00, 0xFF, 0xCC); // mint-teal + private static readonly Color CommentGlowColor = Color.FromRgb(0xFF, 0xD7, 0x00); // gold + private static readonly Color GhostGlowColor = Color.FromRgb(0x44, 0x99, 0xDD); // steel-blue + private static readonly Color LinkGlowColor = Color.FromRgb(0x00, 0xCC, 0xFF); // cyan + private static readonly Color LinkSelGlowColor = Color.FromRgb(0xFF, 0x55, 0x00); // orange-red private static readonly Color SelectionGlowColor = Color.FromRgb(0x22, 0xAA, 0xFF); // bright blue (selected objects) // ── Drawing constants ───────────────────────────────────────────────── - private const double NodeCornerRadius = 4.0; + private const double NodeCornerRadius = 4.0; private const double NodeBorderThickness = 2.0; - private const double LinkThickness = 2.0; - private const double ArrowSize = 10.0; - private const double NodeFontSize = 12.0; - private const double StartFontSize = 11.0; - private const double CommentFontSize = 11.5; - private const double CommentPadding = 6.0; + private const double LinkThickness = 2.0; + private const double ArrowSize = 10.0; + private const double NodeFontSize = 12.0; + private const double StartFontSize = 11.0; + private const double CommentFontSize = 11.5; + private const double CommentPadding = 6.0; /// Size of the dog-ear fold cut at the top-right corner of a sticky note. - private const double CommentFoldSize = 22.0; + private const double CommentFoldSize = 22.0; /// Height of the adhesive-tab band drawn at the top of the sticky note. private const double CommentHeaderHeight = 20.0; /// Vertical spacing between faint ruled lines on the note body. - private const double CommentRuleSpacing = 17.0; + private const double CommentRuleSpacing = 17.0; // Hook layout - private const double NodeHeaderHeight = 28.0; - private const double HookRowHeight = 16.0; - private const double HookDotRadius = 3.5; - private const double HookFontSize = 10.0; - private const double NodeMinWidth = 120.0; + private const double NodeHeaderHeight = 28.0; + private const double HookRowHeight = 16.0; + private const double HookDotRadius = 3.5; + private const double HookFontSize = 10.0; + private const double NodeMinWidth = 120.0; // Hook toggle icon button in the node header top-right private const double HookToggleIconSize = NodeHeaderHeight - 8.0; // Resize handle: square target area at node bottom-right corner - private const double ResizeHandleSize = 14.0; + private const double ResizeHandleSize = 14.0; // Minimize-to-inline button on BasicParameter node header top-left private const double InlineMinimizeButtonSize = NodeHeaderHeight - 8.0; // Multi-link destination index label private const double LinkIndexFontSize = 9.0; - // Elbow routing - private const double ElbowMinOffset = 16.0; - private const double MaxStraightLineDistance = 50.0; private const double LinkHitTolerance = 6.0; // Canvas scaling private const double ScaleStep = 0.10; - private const double ScaleMin = 0.10; - private const double ScaleMax = 4.0; + private const double ScaleMin = 0.10; + private const double ScaleMax = 4.0; // Auto-scroll while dragging: activate within this many screen-pixels from the viewport edge. - private const double AutoScrollZone = 48.0 * 2.0; + private const double AutoScrollZone = 48.0 * 2.0; /// Maximum scroll delta (screen pixels) applied per pointer-move event at the very edge. private const double AutoScrollSpeed = 14.0 / 2.0; private static readonly Typeface DefaultTypeface = new Typeface("Segoe UI, Arial, sans-serif"); // ── ViewModel ───────────────────────────────────────────────────────── - private ModelSystemEditorViewModel? _vm; + private ModelSystemEditorViewModel? _vm; /// Set at the start of each call; true when the app is in light mode. private bool _isLight; /// Tracks the FunctionTemplate we are currently subscribed to for PropertyChanged, @@ -289,18 +285,17 @@ public sealed class ModelSystemCanvas : Control private FunctionTemplateViewModel? _subscribedCurrentFunctionTemplate; // ── Per-frame hook anchor cache (rebuilt in BuildHookAnchorCache) ───── - private readonly Dictionary<(NodeViewModel, NodeHook), Point> - _hookAnchors = new(); - private readonly Dictionary> - _nodeVisibleHooks = new(); - private readonly Dictionary> - _nodeConnectedHooks = new(); + private readonly Dictionary<(NodeViewModel, NodeHook), Point> _hookAnchors = new(); + + private readonly Dictionary> _nodeVisibleHooks = new(); + + private readonly Dictionary> _nodeConnectedHooks = new(); + /// Anchor points for FunctionInstance FunctionParameterHook rows (right-edge dot). - private readonly Dictionary<(FunctionInstanceViewModel, FunctionParameterHook), Point> - _fiHookAnchors = new(); + private readonly Dictionary<(FunctionInstanceViewModel, FunctionParameterHook), Point> _fiHookAnchors = new(); + /// Tracks which FunctionParameterHooks on each FunctionInstance have live links. - private readonly Dictionary> - _fiConnectedHooks = new(); + private readonly Dictionary> _fiConnectedHooks = new(); // ── Inline parameter editor ─────────────────────────────────────────── /// Overlay TextBox used for in-canvas parameter value editing. @@ -331,15 +326,15 @@ private readonly DictionaryOverlay border that contains the variable-name suggestion list. - private readonly Border _varDropdownBorder; + private readonly Border _varDropdownBorder; /// Stack of rows inside the dropdown. private readonly StackPanel _varDropdownStack; /// true while the variable autocomplete dropdown is open. private bool _varDropdownVisible; /// Character offset in where the current token starts. - private int _varTokenStart; + private int _varTokenStart; /// Index of the currently highlighted row in the dropdown. - private int _varSelectedIndex; + private int _varSelectedIndex; /// Maximum number of suggestions shown at once. private const int MaxVarDropdownItems = 8; @@ -375,10 +370,10 @@ private readonly Dictionary TryApplyZoomText(); _zoomMinusBtn = new Button { - Content = "\u2212", // − (minus sign) - FontSize = 13, - Padding = new Thickness(6, 1, 6, 1), - Background = Brushes.Transparent, - Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), + Content = "\u2212", // − (minus sign) + FontSize = 13, + Padding = new Thickness(6, 1, 6, 1), + Background = Brushes.Transparent, + Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), BorderThickness = new Thickness(0), }; _zoomMinusBtn.Click += (_, _) => ApplyScale(_scale - ScaleStep); _zoomPlusBtn = new Button { - Content = "+", - FontSize = 13, - Padding = new Thickness(6, 1, 6, 1), - Background = Brushes.Transparent, - Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), + Content = "+", + FontSize = 13, + Padding = new Thickness(6, 1, 6, 1), + Background = Brushes.Transparent, + Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)), BorderThickness = new Thickness(0), }; _zoomPlusBtn.Click += (_, _) => ApplyScale(_scale + ScaleStep); _zoomBar = new Border { - Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)), + Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)), BorderThickness = new Thickness(1.5), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(4, 3), - Child = new StackPanel + CornerRadius = new CornerRadius(16), + Padding = new Thickness(4, 3), + Child = new StackPanel { Orientation = Orientation.Horizontal, - Spacing = 0, - Children = { _zoomMinusBtn, _zoomTextBox, _zoomPlusBtn }, + Spacing = 0, + Children = { _zoomMinusBtn, _zoomTextBox, _zoomPlusBtn }, }, }; LogicalChildren.Add(_zoomBar); @@ -586,13 +581,13 @@ public ModelSystemCanvas() // ── Right-click context-menu tracking ──────────────────────────────── /// Set when a right-button press is outstanding, so release can compare displacement. - private bool _rightClickPending; + private bool _rightClickPending; /// Canvas position where the right button was pressed. - private Point _rightClickPressPos; + private Point _rightClickPressPos; /// Canvas element (node / start / comment) under the right-button press, if any. private ICanvasElement? _rightClickElement; /// Link under the right-button press when no element was hit. - private LinkViewModel? _rightClickLink; + private LinkViewModel? _rightClickLink; /// Hook dot under the right-button press, if any (may be set alongside ). private (NodeViewModel Node, NodeHook Hook)? _rightClickHookHit; private (FunctionInstanceViewModel Fi, FunctionParameterHook Hook)? _rightClickFiHookHit; @@ -625,21 +620,21 @@ protected override void OnDataContextChanged(EventArgs e) private void Attach() { if (_vm is null) return; - _vm.Nodes.CollectionChanged += OnCollectionChanged; - _vm.Starts.CollectionChanged += OnCollectionChanged; - _vm.Links.CollectionChanged += OnCollectionChanged; - _vm.CommentBlocks.CollectionChanged += OnCollectionChanged; - _vm.GhostNodes.CollectionChanged += OnCollectionChanged; + _vm.Nodes.CollectionChanged += OnCollectionChanged; + _vm.Starts.CollectionChanged += OnCollectionChanged; + _vm.Links.CollectionChanged += OnCollectionChanged; + _vm.CommentBlocks.CollectionChanged += OnCollectionChanged; + _vm.GhostNodes.CollectionChanged += OnCollectionChanged; _vm.FunctionTemplates.CollectionChanged += OnCollectionChanged; _vm.FunctionInstances.CollectionChanged += OnCollectionChanged; _vm.FunctionParameterVMs.CollectionChanged += OnCollectionChanged; - _vm.PropertyChanged += OnViewModelPropertyChanged; + _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; + 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; foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged += OnElementPropertyChanged; foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged += OnElementPropertyChanged; foreach (var fp in _vm.FunctionParameterVMs) ((INotifyPropertyChanged)fp).PropertyChanged += OnElementPropertyChanged; @@ -650,21 +645,21 @@ private void Detach() { if (_vm is null) return; _vm.RenderRequested -= OnRenderRequested; - _vm.Nodes.CollectionChanged -= OnCollectionChanged; - _vm.Starts.CollectionChanged -= OnCollectionChanged; - _vm.Links.CollectionChanged -= OnCollectionChanged; - _vm.CommentBlocks.CollectionChanged -= OnCollectionChanged; - _vm.GhostNodes.CollectionChanged -= OnCollectionChanged; + _vm.Nodes.CollectionChanged -= OnCollectionChanged; + _vm.Starts.CollectionChanged -= OnCollectionChanged; + _vm.Links.CollectionChanged -= OnCollectionChanged; + _vm.CommentBlocks.CollectionChanged -= OnCollectionChanged; + _vm.GhostNodes.CollectionChanged -= OnCollectionChanged; _vm.FunctionTemplates.CollectionChanged -= OnCollectionChanged; _vm.FunctionInstances.CollectionChanged -= OnCollectionChanged; _vm.FunctionParameterVMs.CollectionChanged -= OnCollectionChanged; - _vm.PropertyChanged -= OnViewModelPropertyChanged; + _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; + 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; foreach (var f in _vm.FunctionTemplates) ((INotifyPropertyChanged)f).PropertyChanged -= OnElementPropertyChanged; foreach (var fi in _vm.FunctionInstances) ((INotifyPropertyChanged)fi).PropertyChanged -= OnElementPropertyChanged; foreach (var fp in _vm.FunctionParameterVMs) ((INotifyPropertyChanged)fp).PropertyChanged -= OnElementPropertyChanged; @@ -679,11 +674,19 @@ private void Detach() private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null) + { foreach (INotifyPropertyChanged item in e.NewItems) + { item.PropertyChanged += OnElementPropertyChanged; + } + } if (e.OldItems is not null) + { foreach (INotifyPropertyChanged item in e.OldItems) + { item.PropertyChanged -= OnElementPropertyChanged; + } + } Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateAndMeasure); } @@ -701,7 +704,9 @@ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs if (e.PropertyName is nameof(ModelSystemEditorViewModel.SelectedElement) or nameof(ModelSystemEditorViewModel.SelectedLink) or nameof(ModelSystemEditorViewModel.ShowAllHooks)) + { Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateAndMeasure); + } // When we navigate into or out of a FunctionTemplate, maintain a direct subscription @@ -739,7 +744,7 @@ protected override Size MeasureOverride(Size availableSize) foreach (var n in _vm.Nodes) { if (n.IsInlined) continue; // hidden nodes don't contribute to canvas extents - maxX = Math.Max(maxX, n.X + NodeRenderWidth(n) + 400); + maxX = Math.Max(maxX, n.X + NodeRenderWidth(n) + 400); maxY = Math.Max(maxY, n.Y + NodeRenderHeight(n) + 400); } foreach (var s in _vm.Starts) @@ -749,22 +754,22 @@ protected override Size MeasureOverride(Size availableSize) } foreach (var c in _vm.CommentBlocks) { - maxX = Math.Max(maxX, c.X + c.Width + 400); + maxX = Math.Max(maxX, c.X + c.Width + 400); maxY = Math.Max(maxY, c.Y + c.Height + 400); } foreach (var fi in _vm.FunctionInstances) { - maxX = Math.Max(maxX, fi.X + fi.Width + 400); + maxX = Math.Max(maxX, fi.X + fi.Width + 400); maxY = Math.Max(maxY, fi.Y + fi.Height + 400); } foreach (var ft in _vm.FunctionTemplates) { - maxX = Math.Max(maxX, ft.X + ft.Width + 400); + maxX = Math.Max(maxX, ft.X + ft.Width + 400); maxY = Math.Max(maxY, ft.Y + ft.Height + 400); } foreach (var g in _vm.GhostNodes) { - maxX = Math.Max(maxX, g.X + g.Width + 400); + maxX = Math.Max(maxX, g.X + g.Width + 400); maxY = Math.Max(maxY, g.Y + g.Height + 400); } } @@ -835,7 +840,7 @@ protected override Size ArrangeOverride(Size finalSize) if (child is TextBlock tb) { tb.FontSize = HookFontSize * _scale; - tb.Padding = new Thickness(itemPadH, itemPadV, itemPadH, itemPadV); + tb.Padding = new Thickness(itemPadH, itemPadV, itemPadH, itemPadV); } } _varDropdownBorder.Arrange(new Rect(ddX, ddY, ddW, _varDropdownBorder.DesiredSize.Height)); @@ -871,7 +876,7 @@ or FunctionParameterViewModel double bx = margin, by = margin; if (sv is not null) { - bx = sv.Offset.X + sv.Viewport.Width - zw - margin; + bx = sv.Offset.X + sv.Viewport.Width - zw - margin; by = sv.Offset.Y + sv.Viewport.Height - zh - margin; } _zoomBar.Arrange(new Rect(Math.Max(0, bx), Math.Max(0, by), zw, zh)); @@ -932,28 +937,31 @@ private void DrawGridBackground(DrawingContext ctx, Rect bounds, bool isLight = // Vertical lines for (double x = -phaseX; x < bounds.Width; x += step) + { ctx.DrawLine(pen, new Point(x, 0), new Point(x, bounds.Height)); - + } // Horizontal lines for (double y = -phaseY; y < bounds.Height; y += step) + { ctx.DrawLine(pen, new Point(0, y), new Point(bounds.Width, y)); + } } private void RenderCommentBlocks(DrawingContext ctx) { foreach (var comment in _vm!.CommentBlocks) { - double x = comment.X; - double y = comment.Y; - double w = comment.Width; - double h = comment.Height; - bool sel = comment.IsSelected; + double x = comment.X; + double y = comment.Y; + double w = comment.Width; + double h = comment.Height; + bool sel = comment.IsSelected; double fold = CommentFoldSize; - var fill = sel ? CommentSelFill : CommentFill; + var fill = sel ? CommentSelFill : CommentFill; var borderBrush = sel ? (IBrush)CommentSelBorder : CommentBorderBrush; - var borderPen = new Pen(borderBrush, NodeBorderThickness); - var foldPen = new Pen(borderBrush, 1.0); + var borderPen = new Pen(borderBrush, NodeBorderThickness); + var foldPen = new Pen(borderBrush, 1.0); // ── 1. Drop shadow ──────────────────────────────────────────── // Build a shadow polygon offset by (4, 5) to the bottom-right. @@ -962,11 +970,11 @@ private void RenderCommentBlocks(DrawingContext ctx) var shadowGeo = new StreamGeometry(); using (var gc = shadowGeo.Open()) { - gc.BeginFigure(new Point(x + sx, y + sy ), isFilled: true); - gc.LineTo (new Point(x + w - fold + sx, y + sy )); - gc.LineTo (new Point(x + w + sx, y + fold + sy )); - gc.LineTo (new Point(x + w + sx, y + h + sy )); - gc.LineTo (new Point(x + sx, y + h + sy )); + gc.BeginFigure(new Point(x + sx, y + sy), isFilled: true); + gc.LineTo(new Point(x + w - fold + sx, y + sy)); + gc.LineTo(new Point(x + w + sx, y + fold + sy)); + gc.LineTo(new Point(x + w + sx, y + h + sy)); + gc.LineTo(new Point(x + sx, y + h + sy)); gc.EndFigure(true); } ctx.DrawGeometry(CommentShadowBrush, null, shadowGeo); @@ -980,11 +988,11 @@ private void RenderCommentBlocks(DrawingContext ctx) var bodyGeo = new StreamGeometry(); using (var gc = bodyGeo.Open()) { - gc.BeginFigure(new Point(x, y ), isFilled: true); - gc.LineTo (new Point(x + w - fold, y )); // top edge → fold start - gc.LineTo (new Point(x + w, y + fold)); // fold crease end - gc.LineTo (new Point(x + w, y + h )); // right edge - gc.LineTo (new Point(x, y + h )); // bottom edge + gc.BeginFigure(new Point(x, y), isFilled: true); + gc.LineTo(new Point(x + w - fold, y)); // top edge → fold start + gc.LineTo(new Point(x + w, y + fold)); // fold crease end + gc.LineTo(new Point(x + w, y + h)); // right edge + gc.LineTo(new Point(x, y + h)); // bottom edge gc.EndFigure(true); } ctx.DrawGeometry(fill, borderPen, bodyGeo); @@ -1000,7 +1008,7 @@ private void RenderCommentBlocks(DrawingContext ctx) // ── 5. Faint ruled lines ────────────────────────────────────── { - double ruleLeft = x + CommentPadding; + double ruleLeft = x + CommentPadding; double ruleRight = x + w - CommentPadding; double ruleStart = y + CommentHeaderHeight + CommentRuleSpacing; using var _ = ctx.PushGeometryClip(bodyGeo); @@ -1015,9 +1023,9 @@ private void RenderCommentBlocks(DrawingContext ctx) var foldGeo = new StreamGeometry(); using (var gc = foldGeo.Open()) { - gc.BeginFigure(new Point(x + w - fold, y ), isFilled: true); - gc.LineTo (new Point(x + w, y + fold)); - gc.LineTo (new Point(x + w - fold, y + fold)); + gc.BeginFigure(new Point(x + w - fold, y), isFilled: true); + gc.LineTo(new Point(x + w, y + fold)); + gc.LineTo(new Point(x + w - fold, y + fold)); gc.EndFigure(true); } ctx.DrawGeometry(CommentFoldBackBrush, foldPen, foldGeo); @@ -1025,7 +1033,7 @@ private void RenderCommentBlocks(DrawingContext ctx) // ── 7. Fold crease line ──────────────────────────────────────── ctx.DrawLine(borderPen, new Point(x + w - fold, y), - new Point(x + w, y + fold)); + new Point(x + w, y + fold)); // ── 8. Comment text ─────────────────────────────────────────── var textArea = new Rect( @@ -1051,8 +1059,8 @@ private void RenderCommentBlocks(DrawingContext ctx) // ── 9. Resize grip dots (bottom-right) ──────────────────────── { double dotR = 2.0; - double bx = x + w; - double by = y + h; + double bx = x + w; + double by = y + h; for (int d = 0; d < 3; d++) { double offset = 4.0 + d * 4.0; @@ -1075,13 +1083,13 @@ private void RenderFunctionTemplates(DrawingContext ctx) { foreach (var ft in _vm!.FunctionTemplates) { - double rw = ft.Width; - double rh = ft.Height; - var rect = new Rect(ft.X, ft.Y, rw, rh); + double rw = ft.Width; + double rh = ft.Height; + var rect = new Rect(ft.X, ft.Y, rw, rh); // Neon glow + outer border (dashed to distinguish from a regular node or boundary) var borderBrush = ft.IsSelected ? FtSelBorderBrush : (_isLight ? FtBorderBrushL : FtBorderBrush); - var border = new Pen(borderBrush, NodeBorderThickness + 0.5, dashStyle: FtBorderDash); + var border = new Pen(borderBrush, NodeBorderThickness + 0.5, dashStyle: FtBorderDash); DrawRectGlow(ctx, rect, FtCornerRadius, ft.IsSelected ? SelectionGlowColor : (_isLight ? FtGlowColorL : FtGlowColor)); ctx.DrawRectangle(_isLight ? FtFillL : FtFill, border, rect, FtCornerRadius, FtCornerRadius); @@ -1097,11 +1105,13 @@ private void RenderFunctionTemplates(DrawingContext ctx) // "⊞ TemplateName" label in the header var labelText = "\u229e " + ft.Name; - var labelFt = MakeText(labelText, FtNameFontSize, _isLight ? FtTextBrushL : FtTextBrush); - var lx = ft.X + 8.0; - var ly = ft.Y + (FtHeaderHeight - labelFt.Height) / 2.0; + var labelFt = MakeText(labelText, FtNameFontSize, _isLight ? FtTextBrushL : FtTextBrush); + var lx = ft.X + 8.0; + var ly = ft.Y + (FtHeaderHeight - labelFt.Height) / 2.0; using (ctx.PushClip(new Rect(ft.X + 4, ft.Y, rw - 8, FtHeaderHeight))) + { ctx.DrawText(labelFt, new Point(lx, ly)); + } // ── FunctionParameter hook rows ──────────────────────────────── double rowY = ft.Y + FtHeaderHeight; @@ -1148,15 +1158,15 @@ private void RenderFunctionTemplates(DrawingContext ctx) // ── Resize grip (bottom-right corner) ───────────────────────── { double dotR = 2.0; - double gx = ft.X + rw; - double gy = ft.Y + rh; + double gx = ft.X + rw; + double gy = ft.Y + rh; for (int d = 0; d < 3; d++) { double off = 4.0 + d * 4.0; ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(gx - off + dotR, gy - dotR), dotR, dotR); ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, - new Point(gx - dotR, gy - off + dotR), dotR, dotR); + new Point(gx - dotR, gy - off + dotR), dotR, dotR); } } } @@ -1170,12 +1180,12 @@ private void RenderFunctionInstances(DrawingContext ctx) { foreach (var fi in _vm!.FunctionInstances) { - double rw = fi.Width; - double rh = fi.Height; - var rect = new Rect(fi.X, fi.Y, rw, rh); + double rw = fi.Width; + double rh = fi.Height; + var rect = new Rect(fi.X, fi.Y, rw, rh); var borderBrush = fi.IsSelected ? (_isLight ? FiSelBorderBrushL : FiSelBorderBrush) : (_isLight ? FiBorderBrushL : FiBorderBrush); - var border = new Pen(borderBrush, NodeBorderThickness); + var border = new Pen(borderBrush, NodeBorderThickness); DrawRectGlow(ctx, rect, FiCornerRadius, fi.IsSelected ? SelectionGlowColor : (_isLight ? FiGlowColorL : FiGlowColor)); ctx.DrawRectangle(_isLight ? FiFillL : FiFill, border, rect, FiCornerRadius, FiCornerRadius); @@ -1188,8 +1198,8 @@ private void RenderFunctionInstances(DrawingContext ctx) // "⊡ InstanceName" in header var labelText = "\u22A1 " + fi.Name; var labelFtText = MakeText(labelText, FtNameFontSize, _isLight ? FiTextBrushL : FiTextBrush); - var lx = fi.X + 8.0; - var ly = fi.Y + (FtHeaderHeight - labelFtText.Height) / 2.0; + var lx = fi.X + 8.0; + var ly = fi.Y + (FtHeaderHeight - labelFtText.Height) / 2.0; using (ctx.PushClip(new Rect(fi.X + 4, fi.Y, rw - 8, FtHeaderHeight))) ctx.DrawText(labelFtText, new Point(lx, ly)); @@ -1206,7 +1216,7 @@ private void RenderFunctionInstances(DrawingContext ctx) _fiConnectedHooks.TryGetValue(fi, out var fiConnected); for (int fi_i = 0; fi_i < fi.FunctionParameters.Count; fi_i++) { - var fp = fi.FunctionParameters[fi_i]; + var fp = fi.FunctionParameters[fi_i]; var fpHook = fi_i < fiHooks.Count ? fiHooks[fi_i] as FunctionParameterHook : null; bool fpConn = fiConnected is not null && fpHook is not null && fiConnected.Contains(fpHook); @@ -1220,7 +1230,7 @@ private void RenderFunctionInstances(DrawingContext ctx) // Dot on the RIGHT edge — green if connected, red if not (FP hooks are required). double dotCy = rowY + FtHookRowHeight / 2.0; - var dotBrush = fpConn ? (_isLight ? HookConnectedBrushL : HookConnectedBrush) + var dotBrush = fpConn ? (_isLight ? HookConnectedBrushL : HookConnectedBrush) : (_isLight ? HookUnsatisfiedBrushL : HookUnsatisfiedBrush); ctx.DrawEllipse(dotBrush, null, new Point(fi.X + rw, dotCy), HookDotRadius, HookDotRadius); @@ -1229,8 +1239,8 @@ private void RenderFunctionInstances(DrawingContext ctx) var hookNameFt = MakeText(fp.Name ?? string.Empty, HookFontSize, fpConn ? (_isLight ? HookTextConnBrushL : HookTextConnBrush) : (_isLight ? HookTextUnsatisfiedBrushL : HookTextUnsatisfiedBrush)); - double maxW = rw - textPad * 2 - HookDotRadius * 2; - double hookTy = dotCy - hookNameFt.Height / 2.0; + double maxW = rw - textPad * 2 - HookDotRadius * 2; + double hookTy = dotCy - hookNameFt.Height / 2.0; using (ctx.PushClip(new Rect(fi.X + textPad, hookTy, Math.Max(0, maxW), hookNameFt.Height + 1))) ctx.DrawText(hookNameFt, new Point(fi.X + textPad, hookTy)); @@ -1240,15 +1250,15 @@ private void RenderFunctionInstances(DrawingContext ctx) // ── Resize grip ─────────────────────────────────────────────── { double dotR = 2.0; - double gx = fi.X + rw; - double gy = fi.Y + rh; + double gx = fi.X + rw; + double gy = fi.Y + rh; for (int d = 0; d < 3; d++) { double off = 4.0 + d * 4.0; ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(gx - off + dotR, gy - dotR), dotR, dotR); ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, - new Point(gx - dotR, gy - off + dotR), dotR, dotR); + new Point(gx - dotR, gy - off + dotR), dotR, dotR); } } } @@ -1258,27 +1268,27 @@ private void RenderFunctionParameters(DrawingContext ctx) { foreach (var fp in _vm!.FunctionParameterVMs) { - double rw = fp.Width; - double rh = fp.Height; - var rect = new Rect(fp.X, fp.Y, rw, rh); + double rw = fp.Width; + double rh = fp.Height; + var rect = new Rect(fp.X, fp.Y, rw, rh); // Amber/orange fill — switches between dark and light palettes. IBrush bodyFill, headerFill, textBrush; - Pen border; - Color glowColor; + Pen border; + Color glowColor; if (_isLight) { - bodyFill = fp.IsSelected ? new SolidColorBrush(Colors.PeachPuff) : FpFillL; + bodyFill = fp.IsSelected ? new SolidColorBrush(Colors.PeachPuff) : FpFillL; headerFill = FpHeaderFillL; - border = new Pen(fp.IsSelected ? NodeSelBrush : FpBorderBrushL, NodeBorderThickness); + border = new Pen(fp.IsSelected ? NodeSelBrush : FpBorderBrushL, NodeBorderThickness); textBrush = FpTextBrushL; glowColor = fp.IsSelected ? SelectionGlowColor : FpGlowColorL; } else { - bodyFill = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00)); + bodyFill = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00)); headerFill = new SolidColorBrush(Color.FromArgb(0xFF, 0xC0, 0x50, 0x00)); - border = new Pen(new SolidColorBrush(fp.IsSelected ? Colors.OrangeRed : Colors.DarkOrange), NodeBorderThickness); + border = new Pen(new SolidColorBrush(fp.IsSelected ? Colors.OrangeRed : Colors.DarkOrange), NodeBorderThickness); textBrush = Brushes.White; glowColor = fp.IsSelected ? SelectionGlowColor : Colors.OrangeRed; } @@ -1310,15 +1320,15 @@ private void RenderFunctionParameters(DrawingContext ctx) // Resize grip (same dot pattern as other elements). { double dotR = 2.0; - double gx = fp.X + rw; - double gy = fp.Y + rh; + double gx = fp.X + rw; + double gy = fp.Y + rh; for (int d = 0; d < 3; d++) { double off = 4.0 + d * 4.0; ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(gx - off + dotR, gy - dotR), dotR, dotR); ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, - new Point(gx - dotR, gy - off + dotR), dotR, dotR); + new Point(gx - dotR, gy - off + dotR), dotR, dotR); } } } @@ -1373,7 +1383,7 @@ private void RenderLinks(DrawingContext ctx) // maximum so that every branch can be reached from the shared trunk. const double MinStub = 24.0; double sharedSpineX = p1.X + MinStub; - double trunkTopY = p1.Y; + double trunkTopY = p1.Y; double trunkBottomY = p1.Y; foreach (var sib in siblings) { @@ -1387,11 +1397,11 @@ private void RenderLinks(DrawingContext ctx) if (indivMid > sharedSpineX) sharedSpineX = indivMid; // Track the full Y range so the spine covers every destination. - if (p2.Y < trunkTopY) trunkTopY = p2.Y; + if (p2.Y < trunkTopY) trunkTopY = p2.Y; if (p2.Y > trunkBottomY) trunkBottomY = p2.Y; } - _orthogonalSpineX[(XTMF2.Link)group.Key!] = sharedSpineX; + _orthogonalSpineX[(XTMF2.Link)group.Key!] = sharedSpineX; _orthogonalTrunkRange[(XTMF2.Link)group.Key!] = (trunkTopY, trunkBottomY); } @@ -1403,12 +1413,12 @@ private void RenderLinks(DrawingContext ctx) if (link.Destination is NodeViewModel destNvm && destNvm.IsInlined) continue; var brush = link.IsSelected ? LinkSelBrush : (_isLight ? LinkBrushL : LinkBrush); - var pen = new Pen(brush, LinkThickness); + var pen = new Pen(brush, LinkThickness); // Neon glow: two wider transparent halos drawn beneath the main link line. - var glowColor = link.IsSelected ? LinkSelGlowColor : (_isLight ? LinkGlowColorL : LinkGlowColor); - var glowOuter = new Pen(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 8); - var glowInner = new Pen(new SolidColorBrush(Color.FromArgb(0x26, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 3); + var glowColor = link.IsSelected ? LinkSelGlowColor : (_isLight ? LinkGlowColorL : LinkGlowColor); + var glowOuter = new Pen(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 8); + var glowInner = new Pen(new SolidColorBrush(Color.FromArgb(0x26, glowColor.R, glowColor.G, glowColor.B)), LinkThickness + 3); Point bp2, arrowFrom; Point shaftEnd; @@ -1419,11 +1429,11 @@ private void RenderLinks(DrawingContext ctx) // Use a shared spine X for multi-link groups so all branches overlap on the trunk. _orthogonalSpineX.TryGetValue(link.UnderlyingLink, out var spineX); bool hasSharedSpine = spineX > 0; - var pts = ComputeOrthogonalPath(link, hasSharedSpine ? spineX : (double?)null); + var pts = ComputeOrthogonalPath(link, hasSharedSpine ? spineX : (double?)null); // pts = [p1, corner1, corner2, p2] (always 4 points) - bp2 = pts[^1]; + bp2 = pts[^1]; arrowFrom = BorderArrivalFrom(link.Destination, bp2); - shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); + shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); if (hasSharedSpine) { @@ -1438,11 +1448,11 @@ private void RenderLinks(DrawingContext ctx) // Drawing them as one polyline works when p1.Y is at one extreme; // for the mixed case (branches above AND below) we draw two // segments so the spine covers the complete range. - var p1Trunk = pts[0]; // hook anchor - var corner1 = pts[1]; // (spineX, p1.Y) + var p1Trunk = pts[0]; // hook anchor + var corner1 = pts[1]; // (spineX, p1.Y) _orthogonalTrunkRange.TryGetValue(link.UnderlyingLink, out var range); - var spineTop = new Point(spineX, range.TopY); - var spineBot = new Point(spineX, range.BottomY); + var spineTop = new Point(spineX, range.TopY); + var spineBot = new Point(spineX, range.BottomY); // Horizontal exit + vertical spine as a joined polyline. // The vertical goes from spineTop down to spineBot; corner1 is @@ -1450,7 +1460,7 @@ private void RenderLinks(DrawingContext ctx) // then a separate segment corner1 → spineBot (the other direction). // This draws the T/L shape correctly with a single extra segment. var mainTrunkGeo = MakePolyGeo([p1Trunk, corner1, spineTop]); - var extGeo = MakeSegGeo(corner1, spineBot); + var extGeo = MakeSegGeo(corner1, spineBot); foreach (var trunkPen in new[] { glowOuter, glowInner, pen }) { @@ -1460,20 +1470,20 @@ private void RenderLinks(DrawingContext ctx) } // Branch segment: corner2 → p2 (glow) and corner2 → shaftEnd (stroke). - var branchGlowGeo = MakeSegGeo(pts[^2], pts[^1]); // corner2 → bp2 + var branchGlowGeo = MakeSegGeo(pts[^2], pts[^1]); // corner2 → bp2 var branchShaftGeo = MakeSegGeo(pts[^2], shaftEnd); // corner2 → shaftEnd ctx.DrawGeometry(null, glowOuter, branchGlowGeo); ctx.DrawGeometry(null, glowInner, branchGlowGeo); - ctx.DrawGeometry(null, pen, branchShaftGeo); + ctx.DrawGeometry(null, pen, branchShaftGeo); } else { // Single-destination orthogonal link: draw the full path normally. - var glowGeo = MakePolyGeo(pts); + var glowGeo = MakePolyGeo(pts); var shaftGeo = ReplacePolyGeoLastPoint(pts, shaftEnd); ctx.DrawGeometry(null, glowOuter, glowGeo); ctx.DrawGeometry(null, glowInner, glowGeo); - ctx.DrawGeometry(null, pen, shaftGeo); + ctx.DrawGeometry(null, pen, shaftGeo); } } else @@ -1481,9 +1491,9 @@ private void RenderLinks(DrawingContext ctx) // Draw an S-shaped cubic Bézier curve. Tension adapts to the span so short // links curve gently and long ones sweep broadly, with no elbow kinks. var (bp1, bc1, bc2, bp2c) = ComputeSCurve(link); - bp2 = bp2c; + bp2 = bp2c; arrowFrom = BorderArrivalFrom(link.Destination, bp2); - shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); + shaftEnd = DrawArrow(ctx, brush, arrowFrom, bp2); // Build the geometry once; reuse for glow and main stroke. static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) @@ -1496,12 +1506,12 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) return g; } - var glowGeo = MakeCurveGeo(bp1, bc1, bc2, bp2); + var glowGeo = MakeCurveGeo(bp1, bc1, bc2, bp2); var shaftGeo = MakeCurveGeo(bp1, bc1, bc2, shaftEnd); ctx.DrawGeometry(null, glowOuter, glowGeo); ctx.DrawGeometry(null, glowInner, glowGeo); - ctx.DrawGeometry(null, pen, shaftGeo); + ctx.DrawGeometry(null, pen, shaftGeo); } // For multi-link destinations draw a small 1-based index number @@ -1525,7 +1535,7 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) double ux = adx / dlen, uy = ady / dlen; double nx = -uy, ny = ux; // 90° CCW perpendicular unit vector double offset = ft.Height * 0.5 + 3; - double lx = shaftEnd.X - ft.Width * 0.5 + nx * offset; + double lx = shaftEnd.X - ft.Width * 0.5 + nx * offset; double ly = shaftEnd.Y - ft.Height * 0.5 + ny * offset; ctx.DrawText(ft, new Point(lx, ly)); } @@ -1550,32 +1560,32 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) var destCenter = new Point(link.X2, link.Y2); // p1 and exit direction. - Point p1; + Point p1; Vector exitDir; if (link.Origin is NodeViewModel originNvm && _hookAnchors.TryGetValue((originNvm, link.UnderlyingLink.OriginHook), out var hookPt)) { - p1 = hookPt; + p1 = hookPt; exitDir = new Vector(1, 0); // hooks always face right } else if (link.Origin is FunctionInstanceViewModel fiOriginSC && link.UnderlyingLink.OriginHook is FunctionParameterHook fphSC && _fiHookAnchors.TryGetValue((fiOriginSC, fphSC), out var fiHookPtSC)) { - p1 = fiHookPtSC; + p1 = fiHookPtSC; exitDir = new Vector(1, 0); // FP hook dots always face right } else if (link.Origin is StartViewModel startOrigin) { - var oc = new Point(startOrigin.CenterX, startOrigin.CenterY); - p1 = BorderPoint(link.Origin, destCenter) ?? oc; - var odx = p1.X - oc.X; var ody = p1.Y - oc.Y; - var ol = Math.Sqrt(odx * odx + ody * ody); + var oc = new Point(startOrigin.CenterX, startOrigin.CenterY); + p1 = BorderPoint(link.Origin, destCenter) ?? oc; + var odx = p1.X - oc.X; var ody = p1.Y - oc.Y; + var ol = Math.Sqrt(odx * odx + ody * ody); exitDir = ol < 0.1 ? new Vector(1, 0) : new Vector(odx / ol, ody / ol); } else { - p1 = BorderPoint(link.Origin, destCenter) ?? new Point(link.X1, link.Y1); + p1 = BorderPoint(link.Origin, destCenter) ?? new Point(link.X1, link.Y1); exitDir = new Vector(1, 0); } @@ -1583,11 +1593,11 @@ static StreamGeometry MakeCurveGeo(Point p1, Point c1, Point c2, Point end) var p2 = BorderPoint(link.Destination, p1) ?? destCenter; // c1 follows the exit tangent; c2 steps back from p2 along the entry tangent. - var entryDir = BorderInwardNormal(link.Destination, p2); - double dx = p2.X - p1.X, dy = p2.Y - p1.Y; - double tension = Math.Max(Math.Sqrt(dx * dx + dy * dy) * 0.45, 50.0); + var entryDir = BorderInwardNormal(link.Destination, p2); + double dx = p2.X - p1.X, dy = p2.Y - p1.Y; + double tension = Math.Max(Math.Sqrt(dx * dx + dy * dy) * 0.45, 50.0); - var c1 = new Point(p1.X + exitDir.X * tension, p1.Y + exitDir.Y * tension); + var c1 = new Point(p1.X + exitDir.X * tension, p1.Y + exitDir.Y * tension); var c2 = new Point(p2.X - entryDir.X * tension, p2.Y - entryDir.Y * tension); return (p1, c1, c2, p2); @@ -1614,8 +1624,8 @@ private Point ComputeOrthogonalOriginPoint(LinkViewModel link) if (link.Origin is StartViewModel startOriginO) { - var oc = new Point(startOriginO.CenterX, startOriginO.CenterY); - var r = StartViewModel.Radius; + var oc = new Point(startOriginO.CenterX, startOriginO.CenterY); + var r = StartViewModel.Radius; var dir = destCenter.X >= oc.X ? 1.0 : -1.0; return new Point(oc.X + r * dir, oc.Y); } @@ -1666,7 +1676,7 @@ private Point[] ComputeOrthogonalPath(LinkViewModel link, double? sharedSpineX = var p2est = OrthogonalDestBorderPoint(link.Destination, new Point(p1.X + 1, p1.Y)) ?? destCenter; spineX = (p1.X + p2est.X) * 0.5; - if (spineX < p1.X + MinStub) spineX = p1.X + MinStub; + spineX = Math.Max(spineX, p1.X + MinStub); } // p2 — destination side border point chosen based on which side of the @@ -1692,17 +1702,18 @@ private Point[] ComputeOrthogonalPath(LinkViewModel link, double? sharedSpineX = { if (element is null) return null; - Rect? r = element switch { - NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), - GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), - FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), - FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), - _ => (Rect?)null + Rect? r = element switch + { + NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), + GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), + FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), + FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), + _ => (Rect?)null }; if (r is { } rect) { - double midY = rect.Y + rect.Height * 0.5; + double midY = rect.Y + rect.Height * 0.5; bool goRight = destCenter.X >= rect.X + rect.Width * 0.5; return new Point(goRight ? rect.Right : rect.X, midY); } @@ -1720,18 +1731,19 @@ private Point[] ComputeOrthogonalPath(LinkViewModel link, double? sharedSpineX = { if (element is null) return null; - Rect? r = element switch { - NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), - GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), - FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), - FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), - _ => (Rect?)null + Rect? r = element switch + { + NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), + GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), + FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), + FunctionParameterViewModel fp => new Rect(fp.X, fp.Y, fp.Width, fp.Height), + _ => (Rect?)null }; if (r is { } rect) { - double midY = rect.Y + rect.Height * 0.5; - bool fromLeft = approachFrom.X < rect.X + rect.Width * 0.5; + double midY = rect.Y + rect.Height * 0.5; + bool fromLeft = approachFrom.X < rect.X + rect.Width * 0.5; return new Point(fromLeft ? rect.X : rect.Right, midY); } @@ -1746,7 +1758,9 @@ private static StreamGeometry MakePolyGeo(Point[] pts) using var gc = g.Open(); gc.BeginFigure(pts[0], isFilled: false); for (int i = 1; i < pts.Length; i++) + { gc.LineTo(pts[i]); + } gc.EndFigure(isClosed: false); return g; } @@ -1787,8 +1801,8 @@ private static Point SampleCubicBezier(Point p1, Point c1, Point c2, Point p2, d { double u = 1 - t; return new Point( - u*u*u * p1.X + 3*u*u*t * c1.X + 3*u*t*t * c2.X + t*t*t * p2.X, - u*u*u * p1.Y + 3*u*u*t * c1.Y + 3*u*t*t * c2.Y + t*t*t * p2.Y); + u * u * u * p1.X + 3 * u * u * t * c1.X + 3 * u * t * t * c2.X + t * t * t * p2.X, + u * u * u * p1.Y + 3 * u * u * t * c1.Y + 3 * u * t * t * c2.Y + t * t * t * p2.Y); } /// @@ -1801,25 +1815,26 @@ private Vector BorderInwardNormal(ICanvasElement? dest, Point borderPt) { const double eps = 1.5; - Rect? r = dest switch { - NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), - GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), - FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), - _ => (Rect?)null + Rect? r = dest switch + { + NodeViewModel nvm => new Rect(nvm.X, nvm.Y, NodeRenderWidth(nvm), NodeRenderHeight(nvm)), + GhostNodeViewModel gnvm => new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height), + FunctionInstanceViewModel fiv => new Rect(fiv.X, fiv.Y, fiv.Width, fiv.Height), + _ => (Rect?)null }; if (r is { } rect) { - if (Math.Abs(borderPt.X - rect.X) < eps) return new Vector( 1, 0); // left face → rightward - if (Math.Abs(borderPt.X - rect.Right) < eps) return new Vector(-1, 0); // right face → leftward - if (Math.Abs(borderPt.Y - rect.Y) < eps) return new Vector( 0, 1); // top face → downward - if (Math.Abs(borderPt.Y - rect.Bottom) < eps) return new Vector( 0, -1); // bottom face → upward + if (Math.Abs(borderPt.X - rect.X) < eps) return new Vector(1, 0); // left face → rightward + if (Math.Abs(borderPt.X - rect.Right) < eps) return new Vector(-1, 0); // right face → leftward + if (Math.Abs(borderPt.Y - rect.Y) < eps) return new Vector(0, 1); // top face → downward + if (Math.Abs(borderPt.Y - rect.Bottom) < eps) return new Vector(0, -1); // bottom face → upward } // Circle or unknown: inward radial direction. - var cx = dest?.CenterX ?? borderPt.X; - var cy = dest?.CenterY ?? borderPt.Y; - var ddx = cx - borderPt.X; var ddy = cy - borderPt.Y; + var cx = dest?.CenterX ?? borderPt.X; + var cy = dest?.CenterY ?? borderPt.Y; + var ddx = cx - borderPt.X; var ddy = cy - borderPt.Y; var len = Math.Sqrt(ddx * ddx + ddy * ddy); return len < 0.1 ? new Vector(-1, 0) : new Vector(ddx / len, ddy / len); } @@ -1831,8 +1846,8 @@ private Vector BorderInwardNormal(ICanvasElement? dest, Point borderPt) /// private Point BorderArrivalFrom(ICanvasElement? dest, Point borderPt) { - double back = ArrowSize * 1.5; - var normal = BorderInwardNormal(dest, borderPt); + double back = ArrowSize * 1.5; + var normal = BorderInwardNormal(dest, borderPt); return new Point(borderPt.X - normal.X * back, borderPt.Y - normal.Y * back); } @@ -1903,19 +1918,19 @@ void TryT(double t, bool horizontal, double coord) double other = horizontal ? outside.X + t * dx // x coordinate when checking horizontal side : outside.Y + t * dy; // y coordinate when checking vertical side - if (horizontal && other >= rect.X && other <= rect.Right) tBest = t; + if (horizontal && other >= rect.X && other <= rect.Right) tBest = t; if (!horizontal && other >= rect.Y && other <= rect.Bottom) tBest = t; } if (Math.Abs(dx) > 1e-10) { - TryT((rect.X - outside.X) / dx, horizontal: false, 0); - TryT((rect.Right - outside.X) / dx, horizontal: false, 0); + TryT((rect.X - outside.X) / dx, horizontal: false, 0); + TryT((rect.Right - outside.X) / dx, horizontal: false, 0); } if (Math.Abs(dy) > 1e-10) { - TryT((rect.Y - outside.Y) / dy, horizontal: true, 0); - TryT((rect.Bottom - outside.Y) / dy, horizontal: true, 0); + TryT((rect.Y - outside.Y) / dy, horizontal: true, 0); + TryT((rect.Bottom - outside.Y) / dy, horizontal: true, 0); } if (tBest == double.MaxValue) return center; @@ -1929,8 +1944,8 @@ void TryT(double t, bool horizontal, double coord) /// private static Point DrawArrow(DrawingContext ctx, IBrush brush, Point from, Point to) { - var dx = to.X - from.X; - var dy = to.Y - from.Y; + var dx = to.X - from.X; + var dy = to.Y - from.Y; var len = Math.Sqrt(dx * dx + dy * dy); if (len < 1) return to; @@ -1938,10 +1953,10 @@ private static Point DrawArrow(DrawingContext ctx, IBrush brush, Point from, Poi var ux = dx / len; var uy = dy / len; var px = -uy * (ArrowSize * 0.45); - var py = ux * (ArrowSize * 0.45); + var py = ux * (ArrowSize * 0.45); - var tip = to; - var left = new Point(to.X - ux * ArrowSize + px, to.Y - uy * ArrowSize + py); + var tip = to; + var left = new Point(to.X - ux * ArrowSize + px, to.Y - uy * ArrowSize + py); var right = new Point(to.X - ux * ArrowSize - px, to.Y - uy * ArrowSize - py); var shaftEnd = new Point(to.X - ux * ArrowSize, to.Y - uy * ArrowSize); @@ -1970,27 +1985,27 @@ private void RenderPendingLink(DrawingContext ctx) var cursor = _linkCurrentPos; if (_linkOrigin is StartViewModel pendingStart) { - var oc = new Point(pendingStart.CenterX, pendingStart.CenterY); - p1 = BorderPoint(_linkOrigin, cursor) ?? oc; - var odx = p1.X - oc.X; var ody = p1.Y - oc.Y; - var ol = Math.Sqrt(odx * odx + ody * ody); + var oc = new Point(pendingStart.CenterX, pendingStart.CenterY); + p1 = BorderPoint(_linkOrigin, cursor) ?? oc; + var odx = p1.X - oc.X; var ody = p1.Y - oc.Y; + var ol = Math.Sqrt(odx * odx + ody * ody); exitDir = ol < 0.1 ? new Vector(1, 0) : new Vector(odx / ol, ody / ol); } else { - p1 = new Point(_linkOrigin.CenterX, _linkOrigin.CenterY); + p1 = new Point(_linkOrigin.CenterX, _linkOrigin.CenterY); exitDir = new Vector(1, 0); } var p2 = cursor; // Approach direction for the free cursor end: from origin toward cursor. - var aprDx = p1.X - p2.X; var aprDy = p1.Y - p2.Y; + var aprDx = p1.X - p2.X; var aprDy = p1.Y - p2.Y; var aprLen = Math.Sqrt(aprDx * aprDx + aprDy * aprDy); Vector approachDir = aprLen < 0.1 ? new Vector(-1, 0) : new Vector(aprDx / aprLen, aprDy / aprLen); double dx = p2.X - p1.X, dy = p2.Y - p1.Y; double tension = Math.Max(Math.Sqrt(dx * dx + dy * dy) * 0.45, 50.0); - var c1 = new Point(p1.X + exitDir.X * tension, p1.Y + exitDir.Y * tension); + var c1 = new Point(p1.X + exitDir.X * tension, p1.Y + exitDir.Y * tension); var c2 = new Point(p2.X + approachDir.X * tension, p2.Y + approachDir.Y * tension); // Sample near tip so the pending arrowhead angle is also accurate. @@ -2035,8 +2050,12 @@ private void RenderPendingLink(DrawingContext ctx) var pts = ComputeOrthogonalPath(link, spineX > 0 ? spineX : (double?)null); hit = false; for (int i = 1; i < pts.Length && !hit; i++) + { if (DistToSeg(pos, pts[i - 1], pts[i]) <= LinkHitTolerance) + { hit = true; + } + } } else { @@ -2047,10 +2066,12 @@ private void RenderPendingLink(DrawingContext ctx) hit = false; for (int s = 1; s <= HitSamples && !hit; s++) { - double t = s / (double)HitSamples; - var next = SampleCubicBezier(hp1, hc1, hc2, hp2, t); + double t = s / (double)HitSamples; + var next = SampleCubicBezier(hp1, hc1, hc2, hp2, t); if (DistToSeg(pos, prev, next) <= LinkHitTolerance) + { hit = true; + } prev = next; } } @@ -2110,7 +2131,9 @@ private void RenderPendingLink(DrawingContext ctx) // Retrieve the corresponding FunctionParameterHook from the underlying instance. var hooks = fi.UnderlyingInstance.Hooks; if (i < hooks.Count && hooks[i] is FunctionParameterHook fph) + { return (fi, fph); + } } } } @@ -2120,8 +2143,8 @@ private void RenderPendingLink(DrawingContext ctx) /// Minimum distance from point to segment AB. private static double DistToSeg(Point p, Point a, Point b) { - var dx = b.X - a.X; - var dy = b.Y - a.Y; + var dx = b.X - a.X; + var dy = b.Y - a.Y; var lenSq = dx * dx + dy * dy; double nx, ny; if (lenSq < 1e-10) @@ -2184,9 +2207,13 @@ private void BuildHookAnchorCache() && link.UnderlyingLink.OriginHook.Cardinality == HookCardinality.Single) { if (destVm.IsInlined) + { _hookInlinedParam[(originVm2, link.UnderlyingLink.OriginHook)] = destVm; + } else + { _canInlineNodes.Add(destVm); + } } } @@ -2208,13 +2235,12 @@ private void BuildHookAnchorCache() // Required hooks (Single / AtLeastOne) are always visible. // Optional hooks are shown when the per-node toggle is on. // Connected hooks are always shown so live links remain visible. - visible = node.UnderlyingNode.Hooks + visible = [.. node.UnderlyingNode.Hooks .Where(h => h.Cardinality == HookCardinality.Single || h.Cardinality == HookCardinality.AtLeastOne || node.ShowHooks || - connected.Contains(h)) - .ToList(); + connected.Contains(h))]; } _nodeVisibleHooks[node] = visible; @@ -2248,11 +2274,15 @@ private static double NodeRenderWidth(NodeViewModel node) => private double NodeRenderHeight(NodeViewModel node) { bool hasParamRow = node.IsParameterNode; - int extraRows = hasParamRow ? 1 : 0; + int extraRows = hasParamRow ? 1 : 0; if (_nodeVisibleHooks.TryGetValue(node, out var hooks) && hooks.Count > 0) + { return Math.Max(node.Height, NodeHeaderHeight + (hooks.Count + extraRows) * HookRowHeight); + } if (hasParamRow) + { return Math.Max(node.Height, NodeHeaderHeight + HookRowHeight); + } // No visible hooks — keep at least NodeHeaderHeight so the name always fits. return Math.Max(node.Height, NodeHeaderHeight); } @@ -2286,7 +2316,7 @@ private void RenderNodes(DrawingContext ctx) double rw = NodeRenderWidth(node); double rh = NodeRenderHeight(node); - var rect = new Rect(node.X, node.Y, rw, rh); + var rect = new Rect(node.X, node.Y, rw, rh); // Determine whether this node is the designated entry point of the current template. bool isEntryNode = _vm.IsInsideFunctionTemplate @@ -2322,26 +2352,28 @@ private void RenderNodes(DrawingContext ctx) if (isEntryNode) { var labelBrush = _isLight ? EntryNodeLabelBrushL : EntryNodeLabelBrush; - var badge = MakeText("▶ Entry Point", EntryNodeLabelFontSize, labelBrush); + var badge = MakeText("▶ Entry Point", EntryNodeLabelFontSize, labelBrush); double blx = node.X + 6.0; double bly = node.Y + NodeHeaderHeight - badge.Height - 2.5; using (ctx.PushClip(new Rect(node.X + 2, node.Y, rw - 4, NodeHeaderHeight))) + { ctx.DrawText(badge, new Point(blx, bly)); + } } // ── Resize handle (bottom-right corner) ─────────────────────── // Three small diagonal dots — standard grip indicator. { double dotR = 2.0; - double bx = node.X + rw; - double by = node.Y + rh; + double bx = node.X + rw; + double by = node.Y + rh; for (int d = 0; d < 3; d++) { double offset = 4.0 + d * 4.0; ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - offset + dotR, by - dotR), dotR, dotR); ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, - new Point(bx - dotR, by - offset + dotR), dotR, dotR); + new Point(bx - dotR, by - offset + dotR), dotR, dotR); } } @@ -2350,13 +2382,13 @@ private void RenderNodes(DrawingContext ctx) // global ShowAllHooks override (per-node toggle would be redundant). if (!_vm.ShowAllHooks && node.UnderlyingNode.Hooks.Count > 0) { - var iconRect = HookToggleIconRect(node, rw); - var iconBg = node.ShowHooks ? (_isLight ? HookToggleActiveBgL : HookToggleActiveBg) : (_isLight ? HookToggleBgL : HookToggleBg); + var iconRect = HookToggleIconRect(node, rw); + var iconBg = node.ShowHooks ? (_isLight ? HookToggleActiveBgL : HookToggleActiveBg) : (_isLight ? HookToggleBgL : HookToggleBg); ctx.DrawRectangle(iconBg, null, iconRect, 3.0, 3.0); - var glyph = node.ShowHooks ? "\u25BE" : "\u25B8"; // ▾ or ▸ - var iconFt = MakeText(glyph, HookFontSize + 1.0, _isLight ? HookToggleTextL : HookToggleText); - var glyphX = iconRect.X + (iconRect.Width - iconFt.Width) / 2.0; - var glyphY = iconRect.Y + (iconRect.Height - iconFt.Height) / 2.0; + var glyph = node.ShowHooks ? "\u25BE" : "\u25B8"; // ▾ or ▸ + var iconFt = MakeText(glyph, HookFontSize + 1.0, _isLight ? HookToggleTextL : HookToggleText); + var glyphX = iconRect.X + (iconRect.Width - iconFt.Width) / 2.0; + var glyphY = iconRect.Y + (iconRect.Height - iconFt.Height) / 2.0; ctx.DrawText(iconFt, new Point(glyphX, glyphY)); } @@ -2365,17 +2397,17 @@ private void RenderNodes(DrawingContext ctx) // and can therefore be folded into the parent's hook row. if (_canInlineNodes.Contains(node)) { - var minRect = InlineMinimizeButtonRect(node); + var minRect = InlineMinimizeButtonRect(node); ctx.DrawRectangle(_isLight ? MinimizeBtnBgL : MinimizeBtnBg, null, minRect, 3.0, 3.0); - var minFt = MakeText("\u229f", HookFontSize, _isLight ? MinimizeBtnTextL : MinimizeBtnText); // ⊟ minus-in-box - var minGlX = minRect.X + (minRect.Width - minFt.Width) / 2.0; - var minGlY = minRect.Y + (minRect.Height - minFt.Height) / 2.0; + var minFt = MakeText("\u229f", HookFontSize, _isLight ? MinimizeBtnTextL : MinimizeBtnText); // ⊟ minus-in-box + var minGlX = minRect.X + (minRect.Width - minFt.Width) / 2.0; + var minGlY = minRect.Y + (minRect.Height - minFt.Height) / 2.0; ctx.DrawText(minFt, new Point(minGlX, minGlY)); } // ── Hook rows ───────────────────────────────────────────────── bool hasParamRow = node.IsParameterNode; - bool hasHooks = _nodeVisibleHooks.TryGetValue(node, out var hooks) && hooks.Count > 0; + bool hasHooks = _nodeVisibleHooks.TryGetValue(node, out var hooks) && hooks.Count > 0; if (!hasParamRow && !hasHooks) continue; @@ -2383,7 +2415,7 @@ private void RenderNodes(DrawingContext ctx) // Divider line separating header from content rows var dividerPen = new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 1.0); ctx.DrawLine(dividerPen, - new Point(node.X + 1, headerBottom), + new Point(node.X + 1, headerBottom), new Point(node.X + rw - 1, headerBottom)); int rowOffset = 0; @@ -2403,9 +2435,9 @@ private void RenderNodes(DrawingContext ctx) // the inline TextBox (and syntax overlay) already cover that row. if (node != _editingParamNode) { - var display = string.IsNullOrEmpty(paramValue) ? "(no value)" : paramValue; - var paramFt = MakeText(display, HookFontSize, _isLight ? ParamValueTextBrushL : ParamValueTextBrush); - double maxW = rw - textPad * 2; + var display = string.IsNullOrEmpty(paramValue) ? "(no value)" : paramValue; + var paramFt = MakeText(display, HookFontSize, _isLight ? ParamValueTextBrushL : ParamValueTextBrush); + double maxW = rw - textPad * 2; double paramTy = rowMidY - paramFt.Height / 2.0; using (ctx.PushClip(new Rect(node.X + textPad, paramTy, Math.Max(0, maxW), paramFt.Height + 1))) ctx.DrawText(paramFt, new Point(node.X + textPad, paramTy)); @@ -2418,7 +2450,7 @@ private void RenderNodes(DrawingContext ctx) { double sepY = node.Y + NodeHeaderHeight + HookRowHeight; ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), - new Point(node.X + 1, sepY), + new Point(node.X + 1, sepY), new Point(node.X + rw - 1, sepY)); } } @@ -2430,13 +2462,13 @@ private void RenderNodes(DrawingContext ctx) for (int i = 0; i < hooks!.Count; i++) { - var hook = hooks[i]; + var hook = hooks[i]; bool conn = connected is not null && connected.Contains(hook); // Is this hook occupied by an inlined BasicParameter? bool hasInlined = _hookInlinedParam.TryGetValue((node, hook), out var inlinedParam); // Unsatisfied: required cardinality with no connection at all. - bool isRequired = hook.Cardinality == HookCardinality.Single + bool isRequired = hook.Cardinality == HookCardinality.Single || hook.Cardinality == HookCardinality.AtLeastOne; bool unsatisfied = isRequired && !conn && !hasInlined; @@ -2445,17 +2477,21 @@ private void RenderNodes(DrawingContext ctx) // Tinted background: red for unsatisfied required hooks, amber for inlined params. if (unsatisfied) + { ctx.DrawRectangle(HookUnsatisfiedRowBg, null, new Rect(node.X + 1, rowTopY, rw - 2, HookRowHeight)); + } else if (hasInlined) + { ctx.DrawRectangle(InlineParamRowBg, null, new Rect(node.X + 1, rowTopY, rw - 2, HookRowHeight)); + } // Dot on the right edge (the link anchor). // Red for unsatisfied required hooks, green for connected/inlined, grey otherwise. - var dotBrush = unsatisfied ? (_isLight ? HookUnsatisfiedBrushL : HookUnsatisfiedBrush) - : (conn || hasInlined) ? (_isLight ? HookConnectedBrushL : HookConnectedBrush) - : (_isLight ? HookUnconnectedBrushL : HookUnconnectedBrush); + var dotBrush = unsatisfied ? (_isLight ? HookUnsatisfiedBrushL : HookUnsatisfiedBrush) + : (conn || hasInlined) ? (_isLight ? HookConnectedBrushL : HookConnectedBrush) + : (_isLight ? HookUnconnectedBrushL : HookUnconnectedBrush); ctx.DrawEllipse(dotBrush, null, new Point(node.X + rw, rowMidY), HookDotRadius, HookDotRadius); @@ -2466,11 +2502,11 @@ private void RenderNodes(DrawingContext ctx) ? $"{hook.Name}: {(string.IsNullOrEmpty(inlinedParam.ParameterValueRepresentation) ? "(no value)" : inlinedParam.ParameterValueRepresentation)}" : hook.Name; IBrush hookTextBrush = unsatisfied ? (_isLight ? HookTextUnsatisfiedBrushL : HookTextUnsatisfiedBrush) - : hasInlined ? (_isLight ? ParamValueTextBrushL : ParamValueTextBrush) - : conn ? (_isLight ? HookTextConnBrushL : HookTextConnBrush) - : (_isLight ? HookTextDimBrushL : HookTextDimBrush); - var hookFt = MakeText(hookLabel, HookFontSize, hookTextBrush); - double maxW = rw - textPad * 2 - HookDotRadius * 2; + : hasInlined ? (_isLight ? ParamValueTextBrushL : ParamValueTextBrush) + : conn ? (_isLight ? HookTextConnBrushL : HookTextConnBrush) + : (_isLight ? HookTextDimBrushL : HookTextDimBrush); + var hookFt = MakeText(hookLabel, HookFontSize, hookTextBrush); + double maxW = rw - textPad * 2 - HookDotRadius * 2; double hookTy = rowMidY - hookFt.Height / 2.0; using (ctx.PushClip(new Rect(node.X + textPad, hookTy, Math.Max(0, maxW), hookFt.Height + 1))) ctx.DrawText(hookFt, new Point(node.X + textPad, hookTy)); @@ -2480,7 +2516,7 @@ private void RenderNodes(DrawingContext ctx) { double sepY = node.Y + NodeHeaderHeight + (rowOffset + i + 1) * HookRowHeight; ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), - new Point(node.X + 1, sepY), + new Point(node.X + 1, sepY), new Point(node.X + rw - 1, sepY)); } } @@ -2497,11 +2533,11 @@ private void RenderGhostNodes(DrawingContext ctx) { double rw = ghost.Width; double rh = ghost.Height; - var rect = new Rect(ghost.X, ghost.Y, rw, rh); + var rect = new Rect(ghost.X, ghost.Y, rw, rh); // Fill is semi-transparent; border is dashed. var borderBrush = ghost.IsSelected ? GhostNodeSelBrush : (_isLight ? GhostNodeBorderBrushL : GhostNodeBorderBrush); - var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash); + var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash); DrawRectGlow(ctx, rect, NodeCornerRadius, ghost.IsSelected ? SelectionGlowColor : (_isLight ? GhostGlowColorL : GhostGlowColor)); ctx.DrawRectangle(_isLight ? GhostNodeFillL : GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); @@ -2509,24 +2545,26 @@ private void RenderGhostNodes(DrawingContext ctx) // Ghost icon prefix ("⊙ ") to distinguish from real nodes at a glance. var labelText = "\u2299 " + ghost.Name; var ft = MakeText(labelText, NodeFontSize, _isLight ? NodeTextBrushL : NodeTextBrush); - var tx = ghost.X + (rw - ft.Width) / 2; + 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; + 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(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - offset + dotR, by - dotR), dotR, dotR); ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, - new Point(bx - dotR, by - offset + dotR), dotR, dotR); + new Point(bx - dotR, by - offset + dotR), dotR, dotR); } } } @@ -2536,9 +2574,9 @@ private void RenderStarts(DrawingContext ctx) { foreach (var start in _vm!.Starts) { - var fill = start.IsSelected ? StartSelFill : (_isLight ? StartFillL : StartFill); + var fill = start.IsSelected ? StartSelFill : (_isLight ? StartFillL : StartFill); var center = new Point(start.CenterX, start.CenterY); - var r = StartViewModel.Radius; + var r = StartViewModel.Radius; var border = new Pen(start.IsSelected ? NodeSelBrush : (_isLight ? StartBorderBrushL : NodeBorderBrush), NodeBorderThickness); DrawEllipseGlow(ctx, center, r, r, start.IsSelected ? SelectionGlowColor : (_isLight ? StartGlowColorL : StartGlowColor)); @@ -2561,22 +2599,22 @@ private void UpdateZoomBarColors(bool isLight) if (isLight) { // Light neon pill palette - _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xF4, 0xF8, 0xFF, 0xEE)); - _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xCC)); + _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xF4, 0xF8, 0xFF, 0xEE)); + _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xCC)); _zoomTextBox.Background = new SolidColorBrush(Color.FromArgb(0x0A, 0x00, 0x00, 0x00)); - _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); + _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); _zoomMinusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); - _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); + _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0x00, 0x30, 0x88)); } else { // Dark neon pill palette - _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)); - _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)); + _zoomBar.Background = new SolidColorBrush(Color.FromArgb(0xE6, 0x05, 0x05, 0x10)); + _zoomBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x00, 0xD4, 0xFF)); _zoomTextBox.Background = new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)); - _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); + _zoomTextBox.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); _zoomMinusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); - _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); + _zoomPlusBtn.Foreground = new SolidColorBrush(Color.FromRgb(0xEE, 0xFF, 0xFF)); } } @@ -2588,10 +2626,10 @@ private void UpdateZoomBarColors(bool isLight) private static void DrawRectGlow(DrawingContext ctx, Rect rect, double cornerRadius, Color glowColor) { ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x08, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(14), cornerRadius + 14, cornerRadius + 14); - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(9), cornerRadius + 9, cornerRadius + 9); - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x1E, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(5), cornerRadius + 5, cornerRadius + 5); + ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(9), cornerRadius + 9, cornerRadius + 9); + ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x1E, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(5), cornerRadius + 5, cornerRadius + 5); ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x34, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(2.5), cornerRadius + 2.5, cornerRadius + 2.5); - ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x50, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(1), cornerRadius + 1, cornerRadius + 1); + ctx.DrawRectangle(new SolidColorBrush(Color.FromArgb(0x50, glowColor.R, glowColor.G, glowColor.B)), null, rect.Inflate(1), cornerRadius + 1, cornerRadius + 1); } /// @@ -2601,14 +2639,14 @@ private static void DrawRectGlow(DrawingContext ctx, Rect rect, double cornerRad private static void DrawEllipseGlow(DrawingContext ctx, Point center, double rx, double ry, Color glowColor) { ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x08, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 14, ry + 14); - ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 9, ry + 9); - ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x1E, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 5, ry + 5); + ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x10, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 9, ry + 9); + ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x1E, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 5, ry + 5); ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x34, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 2.5, ry + 2.5); - ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x50, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 1, ry + 1); + ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(0x50, glowColor.R, glowColor.G, glowColor.B)), null, center, rx + 1, ry + 1); } private static FormattedText MakeText(string text, double size, IBrush foreground) => - new FormattedText( + new( text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, @@ -2620,7 +2658,10 @@ private static FormattedText MakeText(string text, double size, IBrush foregroun protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); - if (_vm is null) return; + if (_vm is null) + { + return; + } else if (e.Key is Key.Delete or Key.Back) { if (_multiSelection.Count > 1) @@ -2712,10 +2753,10 @@ private void ApplyScale(double newScale, Point? canvasPivot = null) else { // Keep the viewport centre fixed on the same model coordinate. - double cx = sv.Offset.X + sv.Viewport.Width / 2.0; + double cx = sv.Offset.X + sv.Viewport.Width / 2.0; double cy = sv.Offset.Y + sv.Viewport.Height / 2.0; sv.Offset = new Vector( - Math.Max(0, cx * ratio - sv.Viewport.Width / 2.0), + Math.Max(0, cx * ratio - sv.Viewport.Width / 2.0), Math.Max(0, cy * ratio - sv.Viewport.Height / 2.0)); } } @@ -2752,15 +2793,21 @@ private void TryAutoScrollForDrag(Point svPos) double dx = 0.0, dy = 0.0; if (svPos.X < AutoScrollZone) + { dx = -(AutoScrollZone - svPos.X) / AutoScrollZone * AutoScrollSpeed; + } else if (svPos.X > vw - AutoScrollZone) - dx = (svPos.X - (vw - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; - + { + dx = (svPos.X - (vw - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; + } if (svPos.Y < AutoScrollZone) + { dy = -(AutoScrollZone - svPos.Y) / AutoScrollZone * AutoScrollSpeed; + } else if (svPos.Y > vh - AutoScrollZone) - dy = (svPos.Y - (vh - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; - + { + dy = (svPos.Y - (vh - AutoScrollZone)) / AutoScrollZone * AutoScrollSpeed; + } if (dx != 0.0 || dy != 0.0) { sv.Offset = new Vector( @@ -2827,10 +2874,10 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) } var point = e.GetCurrentPoint(this); - var pos = point.Position; // screen coords - var mpos = ToCanvasPos(pos); // model coords + var pos = point.Position; // screen coords + var mpos = ToCanvasPos(pos); // model coords bool isRightButton = point.Properties.IsRightButtonPressed; - bool isCtrlLeft = !isRightButton + bool isCtrlLeft = !isRightButton && point.Properties.IsLeftButtonPressed && (e.KeyModifiers & KeyModifiers.Control) != 0; @@ -2843,14 +2890,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var resizeHit = HitTestResizeHandle(mpos); if (resizeHit is not null) { - if (_editingParamNode is not null) CommitParamEdit(); + if (_editingParamNode is not null) CommitParamEdit(); if (_editingCommentBlock is not null) CommitCommentEdit(); - if (_editingNameElement is not null) CommitNameEdit(); + if (_editingNameElement is not null) CommitNameEdit(); ClearMultiSelection(); - _resizing = resizeHit; + _resizing = resizeHit; _resizeStartPos = mpos; - _resizeStartW = ElementRenderWidth(resizeHit); - _resizeStartH = ElementRenderHeight(resizeHit); + _resizeStartW = ElementRenderWidth(resizeHit); + _resizeStartH = ElementRenderHeight(resizeHit); _vm.SelectElementCommand.Execute(resizeHit); e.Pointer.Capture(this); Focus(); @@ -2865,9 +2912,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var minimizeHit = HitTestMinimizeButton(mpos); if (minimizeHit is not null) { - if (_editingParamNode is not null) CommitParamEdit(); + if (_editingParamNode is not null) CommitParamEdit(); if (_editingCommentBlock is not null) CommitCommentEdit(); - if (_editingNameElement is not null) CommitNameEdit(); + if (_editingNameElement is not null) CommitNameEdit(); minimizeHit.InlineBasicParameter(); InvalidateAndMeasure(); e.Handled = true; @@ -2902,9 +2949,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) // autocomplete dropdown's TextBlock items), do not commit the edit. if (!e.Handled) { - if (_editingParamNode is not null) CommitParamEdit(); + if (_editingParamNode is not null) CommitParamEdit(); if (_editingCommentBlock is not null) CommitCommentEdit(); - if (_editingNameElement is not null) CommitNameEdit(); + if (_editingNameElement is not null) CommitNameEdit(); } } @@ -3025,13 +3072,13 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) // Track right-button press so we can detect a "no-drag" context-menu click on release. if (isRightButton) { - _rightClickPending = true; + _rightClickPending = true; _rightClickPressPos = pos; // screen coords for distance threshold - _rightClickElement = HitTest(mpos, testComments: true); - _rightClickLink = _rightClickElement is null ? HitTestLink(mpos) : null; + _rightClickElement = HitTest(mpos, testComments: true); + _rightClickLink = _rightClickElement is null ? HitTestLink(mpos) : null; // Also check whether a hook dot was right-clicked on a node. var hookHit = HitTestHook(mpos); - _rightClickHookHit = hookHit.HasValue ? (hookHit.Value.node, hookHit.Value.hook) : null; + _rightClickHookHit = hookHit.HasValue ? (hookHit.Value.node, hookHit.Value.hook) : null; var fiHookHit = HitTestFiHook(mpos); _rightClickFiHookHit = fiHookHit; } @@ -3041,7 +3088,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (hit is NodeViewModel or StartViewModel || (hit is FunctionInstanceViewModel hitFi && hitFi.FunctionParameters.Count > 0)) { - _linkOrigin = hit; + _linkOrigin = hit; _linkCurrentPos = mpos; e.Pointer.Capture(this); Focus(); @@ -3054,9 +3101,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (isCtrlLeft) { // Always commit any open inline edit first. - if (_editingParamNode is not null) CommitParamEdit(); + if (_editingParamNode is not null) CommitParamEdit(); if (_editingCommentBlock is not null) CommitCommentEdit(); - if (_editingNameElement is not null) CommitNameEdit(); + if (_editingNameElement is not null) CommitNameEdit(); if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel or FunctionTemplateViewModel or FunctionInstanceViewModel or FunctionParameterViewModel) { @@ -3092,7 +3139,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) // Ctrl+drag on empty space → begin a rubber-band selection rectangle. ClearMultiSelection(); _vm.SelectElementCommand.Execute(null); - _selRectStart = mpos; + _selRectStart = mpos; _selRectCurrent = mpos; e.Pointer.Capture(this); } @@ -3112,8 +3159,8 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) ClearMultiSelection(); _vm.SelectElementCommand.Execute(hit); } - _dragging = hit; - _dragOffset = new Point(mpos.X - hit.X, mpos.Y - hit.Y); + _dragging = hit; + _dragOffset = new Point(mpos.X - hit.X, mpos.Y - hit.Y); _groupDragLastPos = mpos; e.Pointer.Capture(this); } @@ -3134,9 +3181,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var sv = GetScrollViewer(); if (sv is not null) { - _panning = true; + _panning = true; _panStartScrollPos = e.GetCurrentPoint(sv).Position; - _panStartOffset = sv.Offset; + _panStartOffset = sv.Offset; Cursor = new Cursor(StandardCursorType.SizeAll); e.Pointer.Capture(this); } @@ -3150,7 +3197,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); - var pos = e.GetCurrentPoint(this).Position; // screen coords + var pos = e.GetCurrentPoint(this).Position; // screen coords var mpos = ToCanvasPos(pos); // model coords // Capture ScrollViewer-local position now for auto-scroll use later. @@ -3172,17 +3219,29 @@ protected override void OnPointerMoved(PointerEventArgs e) var dw = mpos.X - _resizeStartPos.X; var dh = mpos.Y - _resizeStartPos.Y; if (_resizing is NodeViewModel resizingNode) + { resizingNode.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } else if (_resizing is CommentBlockViewModel resizingComment) + { resizingComment.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } else if (_resizing is GhostNodeViewModel resizingGhost) + { resizingGhost.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } else if (_resizing is FunctionTemplateViewModel resizingFt) + { resizingFt.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } else if (_resizing is FunctionInstanceViewModel resizingFi) + { resizingFi.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } else if (_resizing is FunctionParameterViewModel resizingFp) + { resizingFp.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh); + } InvalidateAndMeasure(); e.Handled = true; return; @@ -3235,10 +3294,10 @@ protected override void OnPointerMoved(PointerEventArgs e) { double nx = Math.Max(0, el.X + dx); double ny = Math.Max(0, el.Y + dy); - if (el is NodeViewModel gnvm) gnvm.MoveToPreview(nx, ny); - else if (el is StartViewModel gsvm) gsvm.MoveToPreview(nx, ny); + if (el is NodeViewModel gnvm) gnvm.MoveToPreview(nx, ny); + else if (el is StartViewModel gsvm) gsvm.MoveToPreview(nx, ny); else if (el is CommentBlockViewModel gcvm) gcvm.MoveToPreview(nx, ny); - else if (el is GhostNodeViewModel ggvm) ggvm.MoveToPreview(nx, ny); + else if (el is GhostNodeViewModel ggvm) ggvm.MoveToPreview(nx, ny); else if (el is FunctionTemplateViewModel gftvm) gftvm.MoveToPreview(nx, ny); else if (el is FunctionInstanceViewModel gfivm) gfivm.MoveToPreview(nx, ny); else if (el is FunctionParameterViewModel gfpvm) gfpvm.MoveToPreview(nx, ny); @@ -3249,10 +3308,10 @@ protected override void OnPointerMoved(PointerEventArgs e) // Single-element drag: preview only, no session command issued yet. var newX = Math.Max(0, mpos.X - _dragOffset.X); var newY = Math.Max(0, mpos.Y - _dragOffset.Y); - if (_dragging is NodeViewModel nvm) nvm.MoveToPreview(newX, newY); - if (_dragging is StartViewModel svm) svm.MoveToPreview(newX, newY); - if (_dragging is CommentBlockViewModel cvm) cvm.MoveToPreview(newX, newY); - if (_dragging is GhostNodeViewModel gvm) gvm.MoveToPreview(newX, newY); + if (_dragging is NodeViewModel nvm) nvm.MoveToPreview(newX, newY); + if (_dragging is StartViewModel svm) svm.MoveToPreview(newX, newY); + if (_dragging is CommentBlockViewModel cvm) cvm.MoveToPreview(newX, newY); + if (_dragging is GhostNodeViewModel gvm) gvm.MoveToPreview(newX, newY); if (_dragging is FunctionTemplateViewModel ftvm) ftvm.MoveToPreview(newX, newY); if (_dragging is FunctionInstanceViewModel fivm) fivm.MoveToPreview(newX, newY); if (_dragging is FunctionParameterViewModel fpvm2) fpvm2.MoveToPreview(newX, newY); @@ -3281,8 +3340,8 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) { _rightClickPending = false; var rPos = e.GetCurrentPoint(this).Position; - var rdx = rPos.X - _rightClickPressPos.X; - var rdy = rPos.Y - _rightClickPressPos.Y; + var rdx = rPos.X - _rightClickPressPos.X; + var rdy = rPos.Y - _rightClickPressPos.Y; if (Math.Sqrt(rdx * rdx + rdy * rdy) < 3.0) { @@ -3327,18 +3386,7 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) if (_resizing is not null) { // Commit the final size to the session (single undo entry). - if (_resizing is NodeViewModel committingNode) - committingNode.CommitResize(); - else if (_resizing is CommentBlockViewModel committingComment) - committingComment.CommitResize(); - else if (_resizing is GhostNodeViewModel committingGhost) - committingGhost.CommitResize(); - else if (_resizing is FunctionTemplateViewModel committingFt) - committingFt.CommitResize(); - else if (_resizing is FunctionInstanceViewModel committingFi) - committingFi.CommitResize(); - else if (_resizing is FunctionParameterViewModel committingFp) - committingFp.CommitResize(); + _resizing.CommitResize(); _resizing = null; e.Pointer.Capture(null); Cursor = Cursor.Default; @@ -3456,8 +3504,8 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) { // Collect the pending move rectangles from all selected elements and push them // as one CommandBatch so the entire group drag is undone with a single Ctrl+Z. - var nodeMoves = new List<(Node, Rectangle)>(); - var commentMoves = new List<(CommentBlock, Rectangle)>(); + var nodeMoves = new List<(Node, Rectangle)>(); + var commentMoves = new List<(CommentBlock, Rectangle)>(); var templateMoves = new List<(FunctionTemplate, Rectangle)>(); var instanceMoves = new List<(FunctionInstance, Rectangle)>(); @@ -3466,57 +3514,72 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) if (el is NodeViewModel gnvm) { var r = gnvm.TakePendingMoveRect(); - if (r.HasValue) nodeMoves.Add((gnvm.UnderlyingNode, r.Value)); + if (r.HasValue) + { + nodeMoves.Add((gnvm.UnderlyingNode, r.Value)); + } } else if (el is StartViewModel gsvm) { var r = gsvm.TakePendingMoveRect(); - if (r.HasValue) nodeMoves.Add((gsvm.UnderlyingStart, r.Value)); + if (r.HasValue) + { + nodeMoves.Add((gsvm.UnderlyingStart, r.Value)); + } } else if (el is CommentBlockViewModel gcvm) { var r = gcvm.TakePendingMoveRect(); - if (r.HasValue) commentMoves.Add((gcvm.UnderlyingBlock, r.Value)); + if (r.HasValue) + { + commentMoves.Add((gcvm.UnderlyingBlock, r.Value)); + } } else if (el is GhostNodeViewModel ggvm) { var r = ggvm.TakePendingMoveRect(); - if (r.HasValue) nodeMoves.Add((ggvm.UnderlyingGhostNode, r.Value)); + if (r.HasValue) + { + nodeMoves.Add((ggvm.UnderlyingGhostNode, r.Value)); + } } else if (el is FunctionTemplateViewModel gftvm) { var r = gftvm.TakePendingMoveRect(); - if (r.HasValue) templateMoves.Add((gftvm.UnderlyingTemplate, r.Value)); + if (r.HasValue) + { + templateMoves.Add((gftvm.UnderlyingTemplate, r.Value)); + } } else if (el is FunctionInstanceViewModel gfivm) { var r = gfivm.TakePendingMoveRect(); - if (r.HasValue) instanceMoves.Add((gfivm.UnderlyingInstance, r.Value)); + if (r.HasValue) + { + instanceMoves.Add((gfivm.UnderlyingInstance, r.Value)); + } } else if (el is FunctionParameterViewModel gfpvm) { var r = gfpvm.TakePendingMoveRect(); - if (r.HasValue) nodeMoves.Add((gfpvm.UnderlyingParameter, r.Value)); + if (r.HasValue) + { + nodeMoves.Add((gfpvm.UnderlyingParameter, r.Value)); + } } } _vm.Session.MoveElements( _vm.User, - nodeMoves.Count > 0 ? nodeMoves : null, - commentMoves.Count > 0 ? commentMoves : null, + nodeMoves.Count > 0 ? nodeMoves : null, + commentMoves.Count > 0 ? commentMoves : null, templateMoves.Count > 0 ? templateMoves : null, instanceMoves.Count > 0 ? instanceMoves : null, out _); } else { - if (_dragging is NodeViewModel nvm) nvm.CommitMove(); - else if (_dragging is StartViewModel svm) svm.CommitMove(); - else if (_dragging is CommentBlockViewModel cvm) cvm.CommitMove(); - else if (_dragging is GhostNodeViewModel gvm) gvm.CommitMove(); - else if (_dragging is FunctionTemplateViewModel ftvm) ftvm.CommitMove(); - else if (_dragging is FunctionInstanceViewModel fivm) fivm.CommitMove(); - else if (_dragging is FunctionParameterViewModel fpvm) fpvm.CommitMove(); + _dragging?.CommitMove(); } _dragging = null; @@ -3539,27 +3602,26 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) if (element is null && link is null) { var bgMenu = new ContextMenu(); - var vm2 = _vm; var spawnPt = ToCanvasPos(_rightClickPressPos); var addStartItem = new MenuItem { Header = "Add Start…" }; - addStartItem.Click += (_, _) => vm2.AddStartAt(spawnPt.X, spawnPt.Y); + addStartItem.Click += (_, _) => _vm.AddStartAt(spawnPt.X, spawnPt.Y); bgMenu.Items.Add(addStartItem); var addModuleItem = new MenuItem { Header = "Add Module…" }; - addModuleItem.Click += (_, _) => _ = vm2.AddModuleAtAsync(spawnPt.X, spawnPt.Y); + addModuleItem.Click += (_, _) => _ = _vm.AddModuleAtAsync(spawnPt.X, spawnPt.Y); bgMenu.Items.Add(addModuleItem); var addCommentItem = new MenuItem { Header = "Add Comment" }; - addCommentItem.Click += (_, _) => vm2.AddCommentBlockAt(spawnPt.X, spawnPt.Y); + addCommentItem.Click += (_, _) => _vm.AddCommentBlockAt(spawnPt.X, spawnPt.Y); bgMenu.Items.Add(addCommentItem); var addFtItem = new MenuItem { Header = "Add Function Template…" }; - addFtItem.Click += (_, _) => _ = vm2.AddFunctionTemplateAtAsync(spawnPt.X, spawnPt.Y); + addFtItem.Click += (_, _) => _ = _vm.AddFunctionTemplateAtAsync(spawnPt.X, spawnPt.Y); bgMenu.Items.Add(addFtItem); var addFiItem = new MenuItem { Header = "Add Function Instance…" }; - addFiItem.Click += (_, _) => _ = vm2.AddFunctionInstanceAtAsync(spawnPt.X, spawnPt.Y); + addFiItem.Click += (_, _) => _ = _vm.AddFunctionInstanceAtAsync(spawnPt.X, spawnPt.Y); bgMenu.Items.Add(addFiItem); ContextMenu = bgMenu; @@ -3644,7 +3706,7 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) // ── FunctionInstance hook right-click items ──────────────────────────── if (_rightClickFiHookHit is { } fiHookEntry) { - var capturedFiOrigin = fiHookEntry.Fi; + var capturedFiOrigin = fiHookEntry.Fi; var capturedFpHook = fiHookEntry.Hook; var allBoundariesItem = new MenuItem { Header = "Link to node in another boundary…" }; @@ -3671,7 +3733,7 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) if (link?.UnderlyingLink is MultiLink reorderMl) { var capturedReorderMl = reorderMl; - var reorderLinkItem = new MenuItem { Header = "Reorder Destinations…" }; + var reorderLinkItem = new MenuItem { Header = "Reorder Destinations…" }; reorderLinkItem.Click += (_, _) => _ = vm.ReorderLinkDestinationsAsync(capturedReorderMl); menu.Items.Add(reorderLinkItem); menu.Items.Add(new Separator()); @@ -3731,9 +3793,13 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) localVarItem.Click += (_, _) => { if (vm.IsNodeInLocalVariables(paramNode)) + { _ = vm.RemoveNodeFromLocalVariablesAsync(paramNode); + } else + { _ = vm.AddNodeToLocalVariablesAsync(paramNode); + } }; menu.Items.Add(localVarItem); menu.Items.Add(new Separator()); @@ -3749,9 +3815,13 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) varItem.Click += (_, _) => { if (vm.IsNodeInVariables(paramNode)) + { _ = vm.RemoveNodeFromVariablesAsync(paramNode); + } else + { _ = vm.AddNodeToVariablesAsync(paramNode); + } }; menu.Items.Add(varItem); menu.Items.Add(new Separator()); @@ -3915,9 +3985,9 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) // ── "Set as Entry Node" — any node while viewing InternalModules ───────── if (_vm.IsInsideFunctionTemplate && element is NodeViewModel entryNodeCandidate) { - var currentEntry = _vm.CurrentFunctionTemplate?.UnderlyingTemplate.EntryNode; + var currentEntry = _vm.CurrentFunctionTemplate?.UnderlyingTemplate.EntryNode; bool alreadyEntry = ReferenceEquals(currentEntry, entryNodeCandidate.UnderlyingNode); - var entryHeader = alreadyEntry ? "Clear Entry Node" : "Set as Entry Node"; + var entryHeader = alreadyEntry ? "Clear Entry Node" : "Set as Entry Node"; var capturedEntryCandidate = entryNodeCandidate; var entryItem = new MenuItem { Header = entryHeader }; entryItem.Click += async (_, _) => @@ -3935,8 +4005,6 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) ContextMenu.Open(this); } - // ── Resize handle hit-testing ───────────────────────────────────────── - // ── Inline parameter editor ──────────────────────────────────────────────── /// @@ -3952,7 +4020,9 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) double rw = NodeRenderWidth(node); var rowRect = new Rect(node.X, node.Y + NodeHeaderHeight, rw, HookRowHeight); if (rowRect.Contains(pos)) + { return node; + } } return null; } @@ -3970,7 +4040,7 @@ private void UnsubscribeInlineEditorScroll() { if (_inlineEditorSv is not null && _inlineEditorSvHandler is not null) _inlineEditorSv.PropertyChanged -= _inlineEditorSvHandler; - _inlineEditorSv = null; + _inlineEditorSv = null; _inlineEditorSvHandler = null; _scriptOverlay.HorizontalScrollOffset = 0; } @@ -3982,7 +4052,7 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = _editingParamEditorX = rowX >= 0 ? rowX : node.X; _editingParamEditorY = rowY >= 0 ? rowY : node.Y + NodeHeaderHeight; _editingParamEditorW = rowW >= 0 ? rowW : NodeRenderWidth(node); - _inlineEditor.Text = node.ParameterValueRepresentation; + _inlineEditor.Text = node.ParameterValueRepresentation; // For scripted parameters the text is rendered by Render() with syntax colours; // make the TextBox itself transparent so the coloured tokens show through. @@ -3992,7 +4062,7 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; _inlineEditor.CaretBrush = isLight ? Brushes.Black : Brushes.White; _scriptTokens = TokenizeScript(node.ParameterValueRepresentation); - _scriptOverlay.Tokens = _scriptTokens; + _scriptOverlay.Tokens = _scriptTokens; _scriptOverlay.IsVisible = true; // Subscribe to the TextBox's internal ScrollViewer so the overlay shifts @@ -4026,8 +4096,8 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = _inlineEditor.Foreground = ParamValueTextBrush; _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38)); _inlineEditor.CaretBrush = null; // default (uses Foreground) - _scriptTokens = Array.Empty<(string, IBrush)>(); - _scriptOverlay.Tokens = _scriptTokens; + _scriptTokens = Array.Empty<(string, IBrush)>(); + _scriptOverlay.Tokens = _scriptTokens; _scriptOverlay.IsVisible = false; } @@ -4051,12 +4121,13 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = private void CommitParamEdit() { if (_commitParamEditInProgress) return; + _commitParamEditInProgress = true; try { HideVarDropdown(); if (_editingParamNode is null) return; - var node = _editingParamNode; + var node = _editingParamNode; var value = _inlineEditor.Text ?? string.Empty; // Attempt to save. For ScriptedParameter this validates the expression first. @@ -4073,13 +4144,13 @@ private void CommitParamEdit() // Save succeeded – close the editor. UnsubscribeInlineEditorScroll(); - _editingParamNode = null; - _inlineEditor.IsVisible = false; + _editingParamNode = null; + _inlineEditor.IsVisible = false; _inlineEditor.Foreground = ParamValueTextBrush; _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38)); _inlineEditor.CaretBrush = null; - _scriptTokens = Array.Empty<(string, IBrush)>(); - _scriptOverlay.Tokens = _scriptTokens; + _scriptTokens = Array.Empty<(string, IBrush)>(); + _scriptOverlay.Tokens = _scriptTokens; _scriptOverlay.IsVisible = false; InvalidateAndMeasure(); } @@ -4094,13 +4165,13 @@ private void CancelParamEdit() { HideVarDropdown(); UnsubscribeInlineEditorScroll(); - _editingParamNode = null; - _inlineEditor.IsVisible = false; + _editingParamNode = null; + _inlineEditor.IsVisible = false; _inlineEditor.Foreground = ParamValueTextBrush; _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38)); _inlineEditor.CaretBrush = null; - _scriptTokens = Array.Empty<(string, IBrush)>(); - _scriptOverlay.Tokens = _scriptTokens; + _scriptTokens = Array.Empty<(string, IBrush)>(); + _scriptOverlay.Tokens = _scriptTokens; _scriptOverlay.IsVisible = false; InvalidateAndMeasure(); Focus(); @@ -4181,14 +4252,14 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) private (string text, IBrush brush)[] TokenizeScript(string text) { if (_vm is null || string.IsNullOrEmpty(text)) - return Array.Empty<(string, IBrush)>(); + return []; var knownNames = new HashSet( _vm.ModelSystemVariables.Select(v => v.Name) .Concat(_vm.LocalVariables.Select(v => v.Name)), StringComparer.OrdinalIgnoreCase); - var tokens = new List<(string, IBrush)>(); + List<(string, IBrush)> tokens = []; int i = 0; while (i < text.Length) { @@ -4222,8 +4293,8 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) IBrush brush = word switch { "true" or "false" => _isLight ? ScriptKeywordBrushL : ScriptKeywordBrush, - _ => knownNames.Contains(word) - ? (_isLight ? ScriptVarKnownBrushL : ScriptVarKnownBrush) + _ => knownNames.Contains(word) + ? (_isLight ? ScriptVarKnownBrushL : ScriptVarKnownBrush) : (_isLight ? ScriptVarUnknownBrushL : ScriptVarUnknownBrush), }; tokens.Add((word, brush)); @@ -4285,7 +4356,7 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) } else { - _scriptTokens = Array.Empty<(string, IBrush)>(); + _scriptTokens = []; _scriptOverlay.Tokens = _scriptTokens; } @@ -4296,7 +4367,7 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) return; } - var text = _inlineEditor.Text ?? string.Empty; + var text = _inlineEditor.Text ?? string.Empty; var caret = Math.Clamp(_inlineEditor.CaretIndex, 0, text.Length); // Walk backwards from the caret to find the start of the current token. @@ -4305,7 +4376,9 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) { char ch = text[tokenStart - 1]; if (char.IsWhiteSpace(ch) || IsExpressionSpecialChar(ch)) + { break; + } tokenStart--; } @@ -4335,10 +4408,10 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) return; } - var normalBg = isLight + var normalBg = isLight ? new SolidColorBrush(Color.FromRgb(0xF8, 0xF9, 0xFF)) : new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E)); - IBrush normalFg = isLight ? Brushes.Black : Brushes.White; + IBrush normalFg = isLight ? Brushes.Black : Brushes.White; _varDropdownBorder.Background = normalBg; _varDropdownBorder.BorderBrush = isLight @@ -4351,11 +4424,11 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) var captured = name; var tb = new TextBlock { - Text = captured, - Padding = new Thickness(8, 3, 8, 3), + Text = captured, + Padding = new Thickness(8, 3, 8, 3), Foreground = normalFg, Background = normalBg, - FontSize = HookFontSize, + FontSize = HookFontSize, FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), }; tb.PointerEntered += (_, _) => @@ -4387,17 +4460,17 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) private void UpdateDropdownHighlight() { bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; - var normalBg = isLight + var normalBg = isLight ? new SolidColorBrush(Color.FromRgb(0xF8, 0xF9, 0xFF)) : new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E)); var selBg = new SolidColorBrush(Color.FromRgb(0x20, 0x60, 0xA0)); - IBrush normalFg = isLight ? Brushes.Black : Brushes.White; + IBrush normalFg = isLight ? Brushes.Black : Brushes.White; for (int i = 0; i < _varDropdownStack.Children.Count; i++) { if (_varDropdownStack.Children[i] is not TextBlock tb) continue; bool sel = i == _varSelectedIndex; - tb.Background = sel ? selBg : normalBg; + tb.Background = sel ? selBg : normalBg; tb.Foreground = sel ? Brushes.White : normalFg; } } @@ -4406,9 +4479,14 @@ private void UpdateDropdownHighlight() private void SelectCurrentDropdownItem() { int count = _varDropdownStack.Children.Count; - if (_varSelectedIndex < 0 || _varSelectedIndex >= count) return; + if (_varSelectedIndex < 0 || _varSelectedIndex >= count) + { + return; + } if (_varDropdownStack.Children[_varSelectedIndex] is TextBlock tb && tb.Text is { } name) + { CompleteVariable(name); + } } /// @@ -4418,9 +4496,9 @@ private void SelectCurrentDropdownItem() /// private void CompleteVariable(string name) { - var text = _inlineEditor.Text ?? string.Empty; + var text = _inlineEditor.Text ?? string.Empty; var caret = Math.Clamp(_inlineEditor.CaretIndex, 0, text.Length); - _inlineEditor.Text = text[.._varTokenStart] + name + text[caret..]; + _inlineEditor.Text = text[.._varTokenStart] + name + text[caret..]; _inlineEditor.CaretIndex = _varTokenStart + name.Length; HideVarDropdown(); // Clicking a TextBlock item shifted focus to the canvas; return it to the @@ -4434,7 +4512,7 @@ private void CompleteVariable(string name) private void HideVarDropdown() { if (!_varDropdownVisible) return; - _varDropdownVisible = false; + _varDropdownVisible = false; _varDropdownBorder.IsVisible = false; _varDropdownStack.Children.Clear(); InvalidateMeasure(); @@ -4512,8 +4590,8 @@ private void BeginNameEdit(ICanvasElement element) return; } - _editingNameElement = element; - _nameEditor.Text = element.Name; + _editingNameElement = element; + _nameEditor.Text = element.Name; _nameEditor.IsVisible = true; InvalidateMeasure(); Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -4528,23 +4606,25 @@ private void CommitNameEdit() { if (_editingNameElement is null) return; var element = _editingNameElement; - var name = (_nameEditor.Text ?? string.Empty).Trim(); - _editingNameElement = null; + var name = (_nameEditor.Text ?? string.Empty).Trim(); + _editingNameElement = null; _nameEditor.IsVisible = false; if (!string.IsNullOrWhiteSpace(name)) { CommandError? renameError = null; bool ok = element switch { - NodeViewModel nvm => nvm.SetName(name, out _), - StartViewModel svm => svm.SetName(name, out _), + NodeViewModel nvm => nvm.SetName(name, out _), + StartViewModel svm => svm.SetName(name, out _), FunctionTemplateViewModel ftvm => ftvm.SetName(name, out renameError), FunctionInstanceViewModel fivm => fivm.SetName(name, out renameError), FunctionParameterViewModel fpvmC => fpvmC.SetName(name, out renameError), - _ => true, + _ => true, }; if (!ok) + { _vm?.ShowToast(renameError?.Message ?? "Failed to rename.", isError: true, durationMs: 4000); + } } InvalidateAndMeasure(); } @@ -4552,7 +4632,7 @@ private void CommitNameEdit() /// Discards the name edit without saving. private void CancelNameEdit() { - _editingNameElement = null; + _editingNameElement = null; _nameEditor.IsVisible = false; InvalidateAndMeasure(); Focus(); @@ -4576,7 +4656,9 @@ private void OnNameEditorKeyDown(object? sender, KeyEventArgs e) private void OnNameEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { if (_editingNameElement is not null) + { CommitNameEdit(); + } } /// @@ -4588,7 +4670,9 @@ public void BeginNameEditForSelected() if (_vm?.SelectedElement is NodeViewModel or StartViewModel or FunctionTemplateViewModel or FunctionInstanceViewModel or FunctionParameterViewModel) + { BeginNameEdit(_vm.SelectedElement); + } } // ── Inline comment editor helpers ──────────────────────────────────────── @@ -4596,18 +4680,20 @@ or FunctionTemplateViewModel or FunctionInstanceViewModel public void BeginCommentEditForSelected() { if (_vm?.SelectedElement is CommentBlockViewModel comment) + { BeginCommentEdit(comment); + } } /// Shows the multi-line comment editor over . private void BeginCommentEdit(CommentBlockViewModel comment) { - _editingCommentBlock = comment; + _editingCommentBlock = comment; _editingCommentEditorX = comment.X; _editingCommentEditorY = comment.Y; _editingCommentEditorW = comment.Width; _editingCommentEditorH = comment.Height; - _commentEditor.Text = comment.Name; // Name returns the underlying Comment text. + _commentEditor.Text = comment.Name; // Name returns the underlying Comment text. // Pick colours based on the active theme. bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; _commentEditor.Foreground = isLight @@ -4629,8 +4715,8 @@ private void CommitCommentEdit() { if (_editingCommentBlock is null) return; var comment = _editingCommentBlock; - var text = _commentEditor.Text ?? string.Empty; - _editingCommentBlock = null; + var text = _commentEditor.Text ?? string.Empty; + _editingCommentBlock = null; _commentEditor.IsVisible = false; comment.SetText(text); InvalidateAndMeasure(); @@ -4639,7 +4725,7 @@ private void CommitCommentEdit() /// Discards the comment edit without saving. private void CancelCommentEdit() { - _editingCommentBlock = null; + _editingCommentBlock = null; _commentEditor.IsVisible = false; InvalidateAndMeasure(); Focus(); @@ -4664,7 +4750,9 @@ private void OnCommentEditorKeyDown(object? sender, KeyEventArgs e) private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { if (_editingCommentBlock is not null) + { CommitCommentEdit(); + } } /// @@ -4679,69 +4767,31 @@ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.Rou private ICanvasElement? HitTestResizeHandle(Point pos) { if (_vm is null) return null; - foreach (var node in _vm.Nodes) - { - double rw = NodeRenderWidth(node); - double rh = NodeRenderHeight(node); - var handle = new Rect( - node.X + rw - ResizeHandleSize, - node.Y + rh - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - if (handle.Contains(pos)) - return node; - } - foreach (var comment in _vm.CommentBlocks) - { - var handle = new Rect( - comment.X + comment.Width - ResizeHandleSize, - comment.Y + comment.Height - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - 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; - } - foreach (var ft in _vm.FunctionTemplates) - { - var handle = new Rect( - ft.X + ft.Width - ResizeHandleSize, - ft.Y + ft.Height - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - if (handle.Contains(pos)) - return ft; - } - foreach (var fi in _vm.FunctionInstances) - { - var handle = new Rect( - fi.X + fi.Width - ResizeHandleSize, - fi.Y + fi.Height - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - if (handle.Contains(pos)) - return fi; - } - foreach (var fp in _vm.FunctionParameterVMs) + + ICanvasElement? CheckHandle(ObservableCollection collection) where T : ICanvasElement { - var handle = new Rect( - fp.X + fp.Width - ResizeHandleSize, - fp.Y + fp.Height - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - if (handle.Contains(pos)) - return fp; - } - return null; + foreach(var element in collection) + { + double w = element.Width; + double h = element.Height; + double x = element.X; + double y = element.Y; + var handle = new Rect(x + w - ResizeHandleSize, y + h - ResizeHandleSize, + ResizeHandleSize, ResizeHandleSize); + if (handle.Contains(pos)) + { + return element; + } + } + return null; + } + ICanvasElement? hit = CheckHandle(_vm.Nodes) + ?? CheckHandle(_vm.CommentBlocks) + ?? CheckHandle(_vm.GhostNodes) + ?? CheckHandle(_vm.FunctionTemplates) + ?? CheckHandle(_vm.FunctionInstances) + ?? CheckHandle(_vm.FunctionParameterVMs); + return hit; } // ── Hook toggle icon hit-testing ───────────────────────────────────── @@ -4756,10 +4806,12 @@ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.Rou foreach (var node in _vm.Nodes) { if (node.UnderlyingNode.Hooks.Count == 0) continue; - double rw = NodeRenderWidth(node); + double rw = NodeRenderWidth(node); var iconRect = HookToggleIconRect(node, rw); if (iconRect.Contains(pos)) + { return node; + } } return null; } @@ -4805,7 +4857,9 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) foreach (var node in _canInlineNodes) { if (InlineMinimizeButtonRect(node).Contains(pos)) + { return node; + } } return null; } @@ -4814,9 +4868,7 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) /// Returns information about an inlined-param hook row that contains /// , or null when no such row is hit. /// - private (NodeViewModel originNode, NodeHook hook, NodeViewModel paramNode, - double rowX, double rowY, double rowW)? - HitTestInlinedParamRow(Point pos) + private (NodeViewModel originNode, NodeHook hook, NodeViewModel paramNode,double rowX, double rowY, double rowW)? HitTestInlinedParamRow(Point pos) { foreach (var ((originNode, hook), paramNode) in _hookInlinedParam) { @@ -4828,9 +4880,9 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) if (hookIdx < 0) continue; int rowOffset = originNode.IsParameterNode ? 1 : 0; - double rw = NodeRenderWidth(originNode); + double rw = NodeRenderWidth(originNode); double rowTop = originNode.Y + NodeHeaderHeight + (rowOffset + hookIdx) * HookRowHeight; - var rowRect = new Rect(originNode.X, rowTop, rw, HookRowHeight); + var rowRect = new Rect(originNode.X, rowTop, rw, HookRowHeight); if (rowRect.Contains(pos)) return (originNode, hook, paramNode, originNode.X, rowTop, rw); @@ -4844,62 +4896,30 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) { if (_vm is null) return null; - // Starts (highest z-order) - foreach (var start in _vm.Starts) - { - var dx = pos.X - start.CenterX; - var dy = pos.Y - start.CenterY; - if (Math.Sqrt(dx * dx + dy * dy) <= StartViewModel.Radius) - return start; - } - - // Nodes - foreach (var node in _vm.Nodes) + static ICanvasElement? TestHitsElement (ObservableCollection collection, Point pos) where T : ICanvasElement { - if (node.IsInlined) continue; // hidden — not clickable directly - if (new Rect(node.X, node.Y, NodeRenderWidth(node), NodeRenderHeight(node)).Contains(pos)) - return node; - } - - // FunctionParameter nodes (shown only inside InternalModules of a template) - foreach (var fp in _vm.FunctionParameterVMs) - { - if (new Rect(fp.X, fp.Y, fp.Width, fp.Height).Contains(pos)) - return fp; - } - - // Ghost nodes - foreach (var ghost in _vm.GhostNodes) - { - if (new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height).Contains(pos)) - return ghost; + foreach (var element in collection) + { + if (element.IsPointWithin(pos)) + return element; + } + return null; } - // Function-template containers (behind nodes but above comment blocks) - foreach (var ft in _vm.FunctionTemplates) - { - if (new Rect(ft.X, ft.Y, ft.Width, ft.Height).Contains(pos)) - return ft; - } + ICanvasElement? hit = TestHitsElement(_vm.Starts, pos) + ?? TestHitsElement(_vm.Nodes, pos) + ?? TestHitsElement(_vm.FunctionParameterVMs, pos) + ?? TestHitsElement(_vm.GhostNodes, pos) + ?? TestHitsElement(_vm.FunctionTemplates, pos) + ?? TestHitsElement(_vm.FunctionInstances, pos); - // Function-instance boxes (between function templates and comment blocks) - foreach (var fi in _vm.FunctionInstances) - { - if (new Rect(fi.X, fi.Y, fi.Width, fi.Height).Contains(pos)) - return fi; - } - - // Comment blocks (background layer) - if (testComments) + + if (hit is not null) { - foreach (var comment in _vm.CommentBlocks) - { - if (new Rect(comment.X, comment.Y, comment.Width, comment.Height).Contains(pos)) - return comment; - } + return hit; } - return null; + return testComments ? TestHitsElement(_vm.CommentBlocks, pos) : null; } // ── Multi-selection helpers ──────────────────────────────────────────────── @@ -4911,7 +4931,9 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) private void ClearMultiSelection() { foreach (var el in _multiSelection) + { el.IsSelected = false; + } _multiSelection.Clear(); // Also clear IsSelected on the primary selected element (which may not be in // _multiSelection when using single-select). This must happen before zeroing @@ -4919,16 +4941,17 @@ private void ClearMultiSelection() // 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; + } + + _vm?.SelectedElement = null; } /// Returns a that always has non-negative width and height, /// regardless of the relative order of and . private static Rect NormalizeRect(Point p1, Point p2) => - new Rect( - Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y), + new(Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y), Math.Abs(p2.X - p1.X), Math.Abs(p2.Y - p1.Y)); /// @@ -4941,7 +4964,7 @@ private void RenderSelectionRect(DrawingContext ctx) { if (_selRectStart is not { } start) return; var rect = NormalizeRect(start, _selRectCurrent); - var pen = new Pen(Brushes.CornflowerBlue, 1.5 / _scale, SelectionRectDash); + var pen = new Pen(Brushes.CornflowerBlue, 1.5 / _scale, SelectionRectDash); ctx.DrawRectangle(SelectionRectFill, pen, rect, 2 / _scale, 2 / _scale); } diff --git a/src/XTMF2.GUI/ViewModels/ICanvasElement.cs b/src/XTMF2.GUI/ViewModels/ICanvasElement.cs index 9cc238f..2f7a930 100644 --- a/src/XTMF2.GUI/ViewModels/ICanvasElement.cs +++ b/src/XTMF2.GUI/ViewModels/ICanvasElement.cs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ using System.ComponentModel; +using Avalonia; namespace XTMF2.GUI.ViewModels; @@ -46,9 +47,35 @@ public interface ICanvasElement : INotifyPropertyChanged /// The X and Y coordinates of the center of the element on the canvas, in pixels. /// double CenterY { get; } + + /// + /// The width of the element on the canvas, in pixels. Used for layout and hit-testing. + /// + double Width { get; } + + /// + /// The height of the element on the canvas, in pixels. Used for layout and hit-testing. + /// + double Height { get; } + /// /// Whether the element is currently selected. /// bool IsSelected { get; set; } + + /// + /// Commits any pending move of this element to the underlying model. Should be called after a drag operation completes. + /// + void CommitMove(); + /// + /// Commits any pending resize of this element to the underlying model. Should be called after a resize operation completes. + /// + void CommitResize(); + + bool IsPointWithin(Point point) + { + return new Rect(X - Width / 2, Y - Height / 2, Width, Height).Contains(point); + } + } diff --git a/src/XTMF2.GUI/ViewModels/StartViewModel.cs b/src/XTMF2.GUI/ViewModels/StartViewModel.cs index 9aae2fc..0fb6828 100644 --- a/src/XTMF2.GUI/ViewModels/StartViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/StartViewModel.cs @@ -16,7 +16,9 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the You should have received a copy of the GNU General Public License along with XTMF2. If not, see . */ +using System; using System.ComponentModel; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using XTMF2.Configuration; using XTMF2.Editing; @@ -58,6 +60,10 @@ public sealed partial class StartViewModel : ObservableObject, ICanvasElement /// Diameter = 2 * Radius, for Width/Height bindings. public double Diameter => Radius * 2.0; + public double Width => Diameter; + + public double Height => Diameter; + [ObservableProperty] private string _name = string.Empty; [ObservableProperty] private bool _isSelected; @@ -145,4 +151,19 @@ public void MoveTo(double x, double y) /// Rename the start, persisting the change via the session (supports undo/redo). public bool SetName(string name, out CommandError? error) => _session.SetNodeName(_user, UnderlyingStart, name, out error); + + /// + /// Starts are fixed-size, so ignore resize attempts. This method is still required to satisfy the interface. + /// + public void CommitResize() + { + // Starts are fixed-size, so ignore resize attempts. + } + + bool IsPointWithin(Point point) + { + var dx = X - CenterX; + var dy = Y - CenterY; + return (Math.Sqrt(dx * dx + dy * dy) <= StartViewModel.Radius); + } }