diff --git a/src/XTMF2.GUI/App.axaml b/src/XTMF2.GUI/App.axaml index c259e87..f3a362d 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,138 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 962e9d5..162fba8 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -22,25 +22,26 @@ 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; +using Avalonia.Threading; using Avalonia.Media; using Avalonia.Media.TextFormatting; 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; /// /// 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. /// @@ -48,122 +49,253 @@ namespace XTMF2.GUI.Controls; public sealed class ModelSystemCanvas : Control { // ── Brushes / pens (shared, immutable) ─────────────────────────────── - private static readonly IBrush CanvasBackground = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E)); + 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(0x2C, 0x3E, 0x50)); - private static readonly IBrush NodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99)); - private static readonly IBrush NodeSelBrush = Brushes.DodgerBlue; - private static readonly IBrush NodeTextBrush = Brushes.White; - private static readonly IBrush StartFill = new SolidColorBrush(Color.FromRgb(0xE6, 0x7E, 0x22)); - 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(0x7F, 0x8C, 0x8D)); - 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, 0x2C, 0x3E, 0x50)); - private static readonly IBrush GhostNodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99)); - private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue; - private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0); + 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); // 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(0xE6, 0xFF, 0xF0, 0x96)); - private static readonly IBrush CommentSelFill = new SolidColorBrush(Color.FromArgb(0xE6, 0xFF, 0xE0, 0x50)); - private static readonly IBrush CommentBorderBrush = new SolidColorBrush(Color.FromRgb(0xB8, 0xA0, 0x00)); - private static readonly IBrush CommentSelBorder = Brushes.DodgerBlue; - private static readonly IBrush CommentTextBrush = new SolidColorBrush(Color.FromRgb(0x22, 0x1E, 0x00)); + 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)); + /// 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). + private static readonly IBrush CommentFoldBackBrush = new SolidColorBrush(Color.FromArgb(0xD0, 0xFF, 0xFA, 0xD0)); + /// 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); // 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); + + // ── 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 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)); + // Script syntax highlight (light) + 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)); + // 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)); + // 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 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)); + // Resize handle (light) + 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)); + // 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 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)); + 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)); + // 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 GhostGlowColorL = Color.FromRgb(0x33, 0x77, 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 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, 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 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)); + /// 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 // 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; - // Drop-shadow layers (three passes, loosest → tightest, to simulate a soft blur) - private static readonly IBrush ShadowBrush1 = new SolidColorBrush(Color.FromArgb(0x18, 0, 0, 0)); - private static readonly IBrush ShadowBrush2 = new SolidColorBrush(Color.FromArgb(0x22, 0, 0, 0)); - private static readonly IBrush ShadowBrush3 = new SolidColorBrush(Color.FromArgb(0x30, 0, 0, 0)); + // 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 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; + /// 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; // 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; + /// 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; + /// 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, + /// so we can unsubscribe when navigating away. + 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(); + + /// Tracks which FunctionParameterHooks on each FunctionInstance have live links. + private readonly Dictionary> _fiConnectedHooks = new(); // ── Inline parameter editor ─────────────────────────────────────────── /// Overlay TextBox used for in-canvas parameter value editing. @@ -184,21 +316,25 @@ private readonly Dictionary> /// Empty when not editing a ScriptedParameter. /// 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; // ── Scripted-parameter variable autocomplete dropdown ───────────────── /// Overlay 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; @@ -234,8 +370,11 @@ private readonly Dictionary> // ── Canvas scale ─────────────────────────────────────────────────────── private double _scale = 1.0; // ── Zoom control overlay ─────────────────────────────────────────────── - private readonly Border _zoomBar; + private readonly Border _zoomBar; private readonly TextBox _zoomTextBox; + private readonly Button _zoomMinusBtn; + private readonly Button _zoomPlusBtn; + private bool _zoomBarIsLight = false; // tracks last applied theme so we only update on change public ModelSystemCanvas() { @@ -244,18 +383,18 @@ public ModelSystemCanvas() // Build the inline editor once; it lives as a visual child of this canvas. _inlineEditor = new TextBox { - FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), - FontSize = HookFontSize, - Foreground = ParamValueTextBrush, - Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38)), - BorderThickness = new Thickness(1), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC)), - Padding = new Thickness(4, 0, 4, 0), + FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), + FontSize = HookFontSize, + Foreground = ParamValueTextBrush, + Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38)), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC)), + Padding = new Thickness(4, 0, 4, 0), VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, - IsVisible = false, + IsVisible = false, }; - _inlineEditor.KeyDown += OnInlineEditorKeyDown; - _inlineEditor.LostFocus += OnInlineEditorLostFocus; + _inlineEditor.KeyDown += OnInlineEditorKeyDown; + _inlineEditor.LostFocus += OnInlineEditorLostFocus; _inlineEditor.TextChanged += OnInlineEditorTextChanged; LogicalChildren.Add(_inlineEditor); @@ -266,15 +405,15 @@ public ModelSystemCanvas() _scriptOverlay = new ScriptSyntaxOverlay(); LogicalChildren.Add(_scriptOverlay); VisualChildren.Add(_scriptOverlay); - _varDropdownStack = new StackPanel { Orientation = Orientation.Vertical }; + _varDropdownStack = new StackPanel { Orientation = Orientation.Vertical }; _varDropdownBorder = new Border { - Child = _varDropdownStack, - Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC)), + Child = _varDropdownStack, + Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC)), BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(3), - IsVisible = false, + CornerRadius = new CornerRadius(3), + IsVisible = false, }; LogicalChildren.Add(_varDropdownBorder); VisualChildren.Add(_varDropdownBorder); @@ -282,16 +421,16 @@ public ModelSystemCanvas() // Build the multi-line comment editor; Enter inserts a newline, Ctrl+Enter commits. _commentEditor = new TextBox { - FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), - FontSize = CommentFontSize, - Foreground = CommentTextBrush, - Background = new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xF0, 0x96)), + FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), + FontSize = CommentFontSize, + Foreground = CommentTextBrush, + Background = new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xF0, 0x96)), BorderThickness = new Thickness(1), - BorderBrush = CommentBorderBrush, - Padding = new Thickness(6, 4, 6, 4), - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - IsVisible = false, + BorderBrush = CommentBorderBrush, + Padding = new Thickness(6, 4, 6, 4), + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + IsVisible = false, }; _commentEditor.LostFocus += OnCommentEditorLostFocus; // Use the tunneling phase so Ctrl+Enter is intercepted before AcceptsReturn @@ -304,15 +443,15 @@ public ModelSystemCanvas() // Build the single-line name editor; Enter commits, Escape cancels. _nameEditor = new TextBox { - FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), - FontSize = NodeFontSize, - Foreground = NodeTextBrush, - Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x2C, 0x40)), - BorderThickness = new Thickness(1), - BorderBrush = NodeSelBrush, - Padding = new Thickness(4, 0, 4, 0), + FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), + FontSize = NodeFontSize, + Foreground = NodeTextBrush, + Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x2C, 0x40)), + BorderThickness = new Thickness(1), + BorderBrush = NodeSelBrush, + Padding = new Thickness(4, 0, 4, 0), VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, - IsVisible = false, + IsVisible = false, }; _nameEditor.AddHandler(InputElement.KeyDownEvent, OnNameEditorKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); @@ -323,57 +462,71 @@ public ModelSystemCanvas() // ── Zoom control (pinned to viewport bottom-right) ──────────────── _zoomTextBox = new TextBox { - FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), - FontSize = 11, - Foreground = NodeTextBrush, - Background = new SolidColorBrush(Color.FromRgb(0x22, 0x32, 0x44)), - BorderThickness = new Thickness(0), - Padding = new Thickness(4, 1, 4, 1), - Width = 52, + FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"), + FontSize = 11, + 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, VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, - Text = "100%", + Text = "100%", }; - _zoomTextBox.KeyDown += OnZoomTextBoxKeyDown; + _zoomTextBox.KeyDown += OnZoomTextBoxKeyDown; _zoomTextBox.LostFocus += (_, _) => TryApplyZoomText(); - var minusBtn = new Button + _zoomMinusBtn = new Button { - Content = "\u2212", // − (minus sign) - FontSize = 13, - Padding = new Thickness(6, 1, 6, 1), - Background = Brushes.Transparent, - Foreground = NodeTextBrush, + 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), }; - minusBtn.Click += (_, _) => ApplyScale(_scale - ScaleStep); + _zoomMinusBtn.Click += (_, _) => ApplyScale(_scale - ScaleStep); - var plusBtn = new Button + _zoomPlusBtn = new Button { - Content = "+", - FontSize = 13, - Padding = new Thickness(6, 1, 6, 1), - Background = Brushes.Transparent, - Foreground = NodeTextBrush, + 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), }; - plusBtn.Click += (_, _) => ApplyScale(_scale + ScaleStep); + _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), - Child = new StackPanel + 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, - Spacing = 0, - Children = { minusBtn, _zoomTextBox, plusBtn }, + Spacing = 0, + Children = { _zoomMinusBtn, _zoomTextBox, _zoomPlusBtn }, }, }; LogicalChildren.Add(_zoomBar); VisualChildren.Add(_zoomBar); + + // Suppress Avalonia's automatic context-menu-on-right-click behaviour. + // The canvas manages the context menu manually in OnPointerReleased so + // 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 ──────────────────────────────────────────────────────── @@ -381,6 +534,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. @@ -421,15 +581,16 @@ 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; // ── Multi-selection set ─────────────────────────────────────────────── /// @@ -459,45 +620,73 @@ 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.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; + _vm.GhostNodes.CollectionChanged += OnCollectionChanged; + _vm.FunctionTemplates.CollectionChanged += OnCollectionChanged; + _vm.FunctionInstances.CollectionChanged += OnCollectionChanged; + _vm.FunctionParameterVMs.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 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; + _vm.RenderRequested += OnRenderRequested; } private void Detach() { if (_vm is null) return; - _vm.Nodes.CollectionChanged -= OnCollectionChanged; - _vm.Starts.CollectionChanged -= OnCollectionChanged; - _vm.Links.CollectionChanged -= OnCollectionChanged; + _vm.RenderRequested -= OnRenderRequested; + _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; + _vm.GhostNodes.CollectionChanged -= OnCollectionChanged; + _vm.FunctionTemplates.CollectionChanged -= OnCollectionChanged; + _vm.FunctionInstances.CollectionChanged -= OnCollectionChanged; + _vm.FunctionParameterVMs.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 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; + + if (_subscribedCurrentFunctionTemplate is not null) + { + ((INotifyPropertyChanged)_subscribedCurrentFunctionTemplate).PropertyChanged -= OnElementPropertyChanged; + _subscribedCurrentFunctionTemplate = null; + } } 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); } @@ -507,12 +696,36 @@ 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() @@ -531,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) @@ -541,9 +754,24 @@ 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); + maxY = Math.Max(maxY, fi.Y + fi.Height + 400); + } + foreach (var ft in _vm.FunctionTemplates) + { + 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); + maxY = Math.Max(maxY, g.Y + g.Height + 400); + } } // Measure the inline editor so Avalonia knows its desired size. if (_editingParamNode is not null) @@ -559,7 +787,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) @@ -604,6 +832,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. @@ -619,7 +858,10 @@ 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 + or FunctionParameterViewModel + ? FtNameFontSize : NodeFontSize) * _scale; _nameEditor.Arrange(new Rect( _nameEditorX * _scale, _nameEditorY * _scale, @@ -634,7 +876,7 @@ protected override Size ArrangeOverride(Size finalSize) 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)); @@ -647,6 +889,12 @@ public override void Render(DrawingContext ctx) BuildHookAnchorCache(); var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; + _isLight = isLight; + if (isLight != _zoomBarIsLight) + { + bool capture = isLight; + Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateZoomBarColors(capture)); + } ctx.DrawRectangle(isLight ? CanvasBackgroundLight : CanvasBackground, null, bounds); DrawGridBackground(ctx, bounds, isLight); @@ -655,6 +903,9 @@ public override void Render(DrawingContext ctx) using (ctx.PushTransform(Matrix.CreateScale(_scale, _scale))) { RenderCommentBlocks(ctx); + RenderFunctionTemplates(ctx); + RenderFunctionInstances(ctx); + RenderFunctionParameters(ctx); RenderLinks(ctx); RenderNodes(ctx); RenderGhostNodes(ctx); @@ -686,29 +937,113 @@ 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) { - var rect = new Rect(comment.X, comment.Y, comment.Width, comment.Height); - var fill = comment.IsSelected ? CommentSelFill : CommentFill; - var border = new Pen(comment.IsSelected ? CommentSelBorder : CommentBorderBrush, NodeBorderThickness, dashStyle: DashStyle.Dash); + 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 borderBrush = sel ? (IBrush)CommentSelBorder : CommentBorderBrush; + 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. + { + const double sx = 4, sy = 5; + 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.EndFigure(true); + } + ctx.DrawGeometry(CommentShadowBrush, null, shadowGeo); + } + + // ── 2. Glow (selection / ambient) ───────────────────────────── + DrawRectGlow(ctx, new Rect(x, y, w, h), NodeCornerRadius, + sel ? SelectionGlowColor : CommentGlowColor); + + // ── 3. Main note body (dog-ear polygon) ─────────────────────── + 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.EndFigure(true); + } + ctx.DrawGeometry(fill, borderPen, bodyGeo); - DrawRectShadow(ctx, rect, NodeCornerRadius); - ctx.DrawRectangle(fill, border, rect, NodeCornerRadius, NodeCornerRadius); + // ── 4. Adhesive-tab header band ─────────────────────────────── + // Clipped to the body polygon so it doesn't bleed into the fold corner. + { + using var _ = ctx.PushGeometryClip(bodyGeo); + // Header Rectangle: full width but tab stops short of the fold on top row. + ctx.DrawRectangle(CommentHeaderBrush, null, + new Rect(x, y, w, CommentHeaderHeight)); + } - // Render wrapped comment text inside the block with clipping - var textArea = rect.Deflate(CommentPadding); + // ── 5. Faint ruled lines ────────────────────────────────────── + { + double ruleLeft = x + CommentPadding; + double ruleRight = x + w - CommentPadding; + double ruleStart = y + CommentHeaderHeight + CommentRuleSpacing; + using var _ = ctx.PushGeometryClip(bodyGeo); + for (double ry = ruleStart; ry < y + h - CommentPadding; ry += CommentRuleSpacing) + ctx.DrawLine(CommentRulePen, + new Point(ruleLeft, ry), + new Point(ruleRight, ry)); + } + + // ── 6. Fold-flap triangle (back of the turned corner) ───────── + // Triangle: the three points of the folded-over corner area. + 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.EndFigure(true); + } + ctx.DrawGeometry(CommentFoldBackBrush, foldPen, foldGeo); + + // ── 7. Fold crease line ──────────────────────────────────────── + ctx.DrawLine(borderPen, + new Point(x + w - fold, y), + new Point(x + w, y + fold)); + + // ── 8. Comment text ─────────────────────────────────────────── + var textArea = new Rect( + x + CommentPadding, + y + CommentHeaderHeight + 2, + w - CommentPadding * 2, + h - CommentHeaderHeight - CommentPadding - 2); if (textArea.Width > 4 && textArea.Height > 4) { - using var _ = ctx.PushClip(textArea); + using var clipPush = ctx.PushClip(textArea); var layout = new TextLayout( comment.Name, DefaultTypeface, @@ -721,25 +1056,355 @@ private void RenderCommentBlocks(DrawingContext ctx) layout.Draw(ctx, new Point(textArea.X, textArea.Y)); } - // Resize grip dots (bottom-right corner) + // ── 9. Resize grip dots (bottom-right) ──────────────────────── { double dotR = 2.0; - double bx = comment.X + comment.Width; - double by = comment.Y + comment.Height; + double bx = x + w; + double by = y + h; for (int d = 0; d < 3; d++) { double offset = 4.0 + d * 4.0; - ctx.DrawEllipse(ResizeHandleBrush, null, + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - offset + dotR, by - dotR), dotR, dotR); - ctx.DrawEllipse(ResizeHandleBrush, null, + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - dotR, by - offset + dotR), dotR, dotR); } } } } + /// + /// 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); + + // 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); + DrawRectGlow(ctx, rect, FtCornerRadius, ft.IsSelected ? SelectionGlowColor : (_isLight ? FtGlowColorL : FtGlowColor)); + ctx.DrawRectangle(_isLight ? FtFillL : 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(_isLight ? FtHeaderFillL : FtHeaderFill, null, rect, FtCornerRadius, FtCornerRadius); + ctx.DrawRectangle(_isLight ? FtFillL : 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, _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; + foreach (var fp in ft.FunctionParameters) + { + ctx.DrawRectangle(InlineParamRowBg, null, + new Rect(ft.X, rowY, rw, FtHookRowHeight)); + ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), + new Point(ft.X, rowY), + new Point(ft.X + rw, rowY)); + + // Dot on the left edge (acts like a hook anchor) — orange to signal FunctionParameter + double dotCx = ft.X + HookDotRadius + 4.0; + double dotCy = rowY + FtHookRowHeight / 2.0; + ctx.DrawEllipse(Brushes.OrangeRed, null, + new Point(dotCx, dotCy), HookDotRadius, HookDotRadius); + + // "Parameter: " + var nodeNameFt = MakeText( + "Parameter: " + (fp.Name ?? string.Empty), HookFontSize, _isLight ? FtHookTextBrushL : 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, _isLight ? FtCountTextBrushL : 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(_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); + } + } + } + } + + /// + /// 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 ? (_isLight ? FiSelBorderBrushL : FiSelBorderBrush) : (_isLight ? FiBorderBrushL : FiBorderBrush); + 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); + + // ── Header band ──────────────────────────────────────────────── + ctx.DrawRectangle(_isLight ? FiHeaderFillL : FiHeaderFill, null, rect, FiCornerRadius, FiCornerRadius); + ctx.DrawRectangle(_isLight ? FiFillL : 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, _isLight ? FiTextBrushL : 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, _isLight ? FiSubTextBrushL : 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)); + + // ── FunctionParameter hook rows ──────────────────────────────── + double rowY = fi.Y + FtHeaderHeight; + var fiHooks = fi.UnderlyingInstance.Hooks; + _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 fpHook = fi_i < fiHooks.Count ? fiHooks[fi_i] as FunctionParameterHook : null; + bool fpConn = fiConnected is not null && fpHook is not null && fiConnected.Contains(fpHook); + + // Tinted background matching unsatisfied hook style (FP hooks are always required). + if (!fpConn) + ctx.DrawRectangle(InlineParamRowBg, null, + new Rect(fi.X, rowY, rw, FtHookRowHeight)); + + ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), + new Point(fi.X, rowY), new Point(fi.X + rw, rowY)); + + // 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) + : (_isLight ? HookUnsatisfiedBrushL : HookUnsatisfiedBrush); + ctx.DrawEllipse(dotBrush, null, + new Point(fi.X + rw, dotCy), HookDotRadius, HookDotRadius); + + const double textPad = 6.0; + 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; + 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)); + + 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(_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); + } + } + } + } + + 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); + + // 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); + + // 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, rw, rh - FtHeaderHeight)); + ctx.DrawRectangle(null, border, rect, FiCornerRadius, FiCornerRadius); + + // 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))) + ctx.DrawText(labelFtText, new Point(lx, ly)); + + // Type name in smaller text below header. + if (!string.IsNullOrEmpty(fp.TypeName)) + { + 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)); + } + + // Resize grip (same dot pattern as other elements). + { + double dotR = 2.0; + 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); + } + } + } + } + + /// + /// 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 @@ -747,52 +1412,130 @@ private void RenderLinks(DrawingContext ctx) if (link.Destination is null) continue; if (link.Destination is NodeViewModel destNvm && destNvm.IsInlined) continue; - var brush = link.IsSelected ? LinkSelBrush : LinkBrush; - var pen = new Pen(brush, LinkThickness); + var brush = link.IsSelected ? LinkSelBrush : (_isLight ? LinkBrushL : LinkBrush); + 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); + + Point bp2, arrowFrom; + Point shaftEnd; - // Use centre-to-centre distance to decide: when the two elements are - // closer than ElbowMinOffset an elbow looks cramped, so draw a straight - // line from the natural exit point of the origin to the nearest border - // of the destination (bypassing the forced midX offset in ComputeElbow). - double cdx = link.X2 - link.X1, cdy = link.Y2 - link.Y1; - Point approachFrom, arrowTip, shaftEnd; - if (Math.Sqrt(cdx * cdx + cdy * cdy) < MaxStraightLineDistance) + if (link.UnderlyingLink.IsOrthogonal) { - var (sp1, sp2) = ComputeDirectLine(link); - shaftEnd = DrawArrow(ctx, brush, sp1, sp2); - ctx.DrawLine(pen, sp1, shaftEnd); - approachFrom = sp1; - arrowTip = sp2; + // 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 { - var (p1, mid1, mid2, p2) = ComputeElbow(link); - shaftEnd = DrawArrow(ctx, brush, mid2, p2); - ctx.DrawLine(pen, p1, mid1); - ctx.DrawLine(pen, mid1, mid2); - ctx.DrawLine(pen, mid2, shaftEnd); - approachFrom = mid2; - arrowTip = p2; + // 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; + } + + 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); } // 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); - 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; + 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)); } @@ -802,150 +1545,310 @@ private void RenderLinks(DrawingContext ctx) } /// - /// 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. + /// Computes cubic Bézier control points for a direction-aware link curve. + /// + /// c1 is placed along the exit tangent at p1 (rightward for hook anchors, + /// radially outward for Start nodes). c2 is placed along the entry tangent + /// at p2, derived from the inward normal of the destination border face, so the + /// curve arrives smoothly perpendicular to that face. Tension is proportional to + /// the Euclidean distance between p1 and p2 so the curve scales naturally at any + /// zoom and in any direction. + /// /// - private (Point p1, Point mid1, Point mid2, Point p2) ComputeElbow(LinkViewModel link) + private (Point p1, Point c1, Point c2, Point p2) ComputeSCurve(LinkViewModel link) { - var originCenter = new Point(link.X1, link.Y1); - var destCenter = new Point(link.X2, link.Y2); + var destCenter = new Point(link.X2, link.Y2); - // p1: hook anchor when origin is a Node, else border-clip. + // p1 and exit direction. Point p1; - bool hookOrigin = false; + Vector exitDir; if (link.Origin is NodeViewModel originNvm && _hookAnchors.TryGetValue((originNvm, link.UnderlyingLink.OriginHook), out var hookPt)) { - p1 = hookPt; - hookOrigin = true; + 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; + 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); + exitDir = ol < 0.1 ? new Vector(1, 0) : new Vector(odx / ol, ody / ol); } else { - p1 = BorderPoint(link.Origin, destCenter) ?? originCenter; + p1 = BorderPoint(link.Origin, destCenter) ?? new Point(link.X1, link.Y1); + exitDir = new Vector(1, 0); } - // 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); + // p2: destination border point approached from p1's direction. + 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 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); + } - Point mid1, mid2, p2; - if (hvh) + /// + /// 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) { - double midX = Math.Max(p1.X + ElbowMinOffset, (p1.X + destCenter.X) / 2.0); - mid1 = new Point(midX, p1.Y); + 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); + } - // 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; - } + return OrthogonalOriginBorderPoint(link.Origin, destCenter) + ?? new Point(link.X1, link.Y1); + } - 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); - } + /// + /// 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 { - double midY = (p1.Y + destCenter.Y) / 2.0; - mid1 = new Point(p1.X, midY); + // 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; + spineX = Math.Max(spineX, p1.X + MinStub); + } - // 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; - } + // 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; - 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); - } + // 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 (p1, mid1, mid2, p2); + + return BorderPoint(element, destCenter); } /// - /// 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. - /// Used when the two elements are too close for an elbow to look reasonable. + /// 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 p1, Point p2) ComputeDirectLine(LinkViewModel link) + private Point? OrthogonalDestBorderPoint(ICanvasElement? element, Point approachFrom) { - var destCenter = new Point(link.X2, link.Y2); + if (element is null) return null; - // 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); + 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 + }; - // p2: destination border point along the p1→dest direction. - var p2 = BorderPoint(link.Destination, p1) ?? destCenter; - return (p1, p2); + 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) + { + 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); + } + + /// + /// Returns the unit vector pointing into through + /// the border face that sits on. + /// For axis-aligned rect borders this is always one of ±X or ±Y. + /// For circles (Start nodes) it is the inward radius direction. + /// + 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 + }; + + 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 + } + + // 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 len = Math.Sqrt(ddx * ddx + ddy * ddy); + return len < 0.1 ? new Vector(-1, 0) : new Vector(ddx / len, ddy / len); + } + + /// + /// Returns a point one arrowhead-length back from along the + /// inward-facing normal of the destination border face, so the arrowhead always + /// arrives perpendicular to that face. + /// + private Point BorderArrivalFrom(ICanvasElement? dest, Point borderPt) + { + double back = ArrowSize * 1.5; + var normal = BorderInwardNormal(dest, borderPt); + return new Point(borderPt.X - normal.X * back, borderPt.Y - normal.Y * back); } /// @@ -969,6 +1872,18 @@ 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 FunctionParameterViewModel fpvmBP) + { + var rect = new Rect(fpvmBP.X, fpvmBP.Y, fpvmBP.Width, fpvmBP.Height); + return ClipLineToRect(other, rect); + } + if (element is StartViewModel) { var center = new Point(element.CenterX, element.CenterY); @@ -1003,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; @@ -1029,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; @@ -1038,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); @@ -1062,10 +1977,52 @@ private static Point DrawArrow(DrawingContext ctx, IBrush brush, Point from, Poi private void RenderPendingLink(DrawingContext ctx) { if (_linkOrigin is null) return; - var pen = new Pen(PendingLinkBrush, LinkThickness, dashStyle: PendingLinkDash); - var origin = new Point(_linkOrigin.CenterX, _linkOrigin.CenterY); - var shaftEnd = DrawArrow(ctx, PendingLinkBrush, origin, _linkCurrentPos); - ctx.DrawLine(pen, origin, shaftEnd); + var pen = new Pen(PendingLinkBrush, LinkThickness, dashStyle: PendingLinkDash); + + // p1 and exit direction follow the same rules as ComputeSCurve. + Point p1; + Vector exitDir; + 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); + exitDir = ol < 0.1 ? new Vector(1, 0) : new Vector(odx / ol, ody / ol); + } + else + { + 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 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 c2 = new Point(p2.X + approachDir.X * tension, p2.Y + approachDir.Y * tension); + + // Sample near tip so the pending arrowhead angle is also accurate. + var pendingArrowFrom = SampleCubicBezier(p1, c1, c2, p2, 0.97); + var shaftEnd = DrawArrow(ctx, PendingLinkBrush, pendingArrowFrom, p2); + + var geo = new StreamGeometry(); + using (var gc = geo.Open()) + { + gc.BeginFigure(p1, isFilled: false); + gc.CubicBezierTo(c1, c2, shaftEnd); + gc.EndFigure(isClosed: false); + } + var pgColor = Color.FromRgb(0x2E, 0xCC, 0x71); + ctx.DrawGeometry(null, new Pen(new SolidColorBrush(Color.FromArgb(0x10, pgColor.R, pgColor.G, pgColor.B)), LinkThickness + 8), geo); + ctx.DrawGeometry(null, new Pen(new SolidColorBrush(Color.FromArgb(0x26, pgColor.R, pgColor.G, pgColor.B)), LinkThickness + 3), geo); + ctx.DrawGeometry(null, pen, geo); } // ── Link hit-testing ────────────────────────────────────────────────── @@ -1084,21 +2041,41 @@ 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; - double hcdx = link.X2 - link.X1, hcdy = link.Y2 - link.Y1; - if (Math.Sqrt(hcdx * hcdx + hcdy * hcdy) < ElbowMinOffset) + bool hit; + if (link.UnderlyingLink.IsOrthogonal) { - var (sp1, sp2) = ComputeDirectLine(link); - if (DistToSeg(pos, sp1, sp2) <= LinkHitTolerance) - return link; + // 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; + } + } } else { - var (p1, mid1, mid2, p2) = ComputeElbow(link); - if (DistToSeg(pos, p1, mid1) <= LinkHitTolerance || - DistToSeg(pos, mid1, mid2) <= LinkHitTolerance || - DistToSeg(pos, mid2, p2) <= LinkHitTolerance) - return link; + // 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; } @@ -1135,11 +2112,39 @@ private void RenderPendingLink(DrawingContext ctx) return null; } + /// + /// Returns the and its + /// whose hook row contains , or null if none. + /// + private (FunctionInstanceViewModel fi, FunctionParameterHook hook)? HitTestFiHook(Point pos) + { + if (_vm is null) return null; + foreach (var fi in _vm.FunctionInstances) + { + if (pos.X < fi.X || pos.X > fi.X + fi.Width) continue; + var fps = fi.FunctionParameters; + for (int i = 0; i < fps.Count; i++) + { + double rowTop = fi.Y + FtHeaderHeight + i * FtHookRowHeight; + if (pos.Y >= rowTop && pos.Y < rowTop + FtHookRowHeight) + { + // 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); + } + } + } + } + return null; + } + /// 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) @@ -1169,6 +2174,8 @@ private void BuildHookAnchorCache() _nodeConnectedHooks.Clear(); _hookInlinedParam.Clear(); _canInlineNodes.Clear(); + _fiHookAnchors.Clear(); + _fiConnectedHooks.Clear(); if (_vm is null) return; // Which hooks on each node have a live link? @@ -1180,6 +2187,14 @@ private void BuildHookAnchorCache() _nodeConnectedHooks[originVm] = set = new HashSet(); set.Add(link.UnderlyingLink.OriginHook); } + // Which FunctionParameterHooks on each FI have a live link? + if (link.Origin is FunctionInstanceViewModel fiOriginVm + && link.UnderlyingLink.OriginHook is FunctionParameterHook fphConnected) + { + if (!_fiConnectedHooks.TryGetValue(fiOriginVm, out var fiSet)) + _fiConnectedHooks[fiOriginVm] = fiSet = new HashSet(); + fiSet.Add(fphConnected); + } } // Identify inlined BasicParameter nodes and which hook rows they occupy. @@ -1192,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); + } } } @@ -1216,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; @@ -1234,6 +2252,20 @@ private void BuildHookAnchorCache() _hookAnchors[(node, visible[i])] = new Point(node.X + rw, ay); } } + + // Register FunctionInstance FunctionParameterHook anchors (right edge of each hook row). + foreach (var fi in _vm.FunctionInstances) + { + var fiHooks = fi.UnderlyingInstance.Hooks; + for (int i = 0; i < fiHooks.Count; i++) + { + if (fiHooks[i] is FunctionParameterHook fph) + { + double rowMidY = fi.Y + FtHeaderHeight + i * FtHookRowHeight + FtHookRowHeight / 2.0; + _fiHookAnchors[(fi, fph)] = new Point(fi.X + fi.Width, rowMidY); + } + } + } } private static double NodeRenderWidth(NodeViewModel node) => @@ -1242,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); } @@ -1256,6 +2292,9 @@ 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 + : el is FunctionParameterViewModel fpvm ? fpvm.Width : 0; /// Returns the rendered height of any resizable canvas element. @@ -1263,6 +2302,9 @@ 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 + : el is FunctionParameterViewModel fpvm ? fpvm.Height : 0; private void RenderNodes(DrawingContext ctx) @@ -1274,33 +2316,64 @@ private void RenderNodes(DrawingContext ctx) double rw = NodeRenderWidth(node); double rh = NodeRenderHeight(node); - var rect = new Rect(node.X, node.Y, rw, rh); - var border = new Pen(node.IsSelected ? NodeSelBrush : NodeBorderBrush, NodeBorderThickness); + 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 : (_isLight ? NodeBorderBrushL : NodeBorderBrush), NodeBorderThickness); // Node background + border - DrawRectShadow(ctx, rect, NodeCornerRadius); - ctx.DrawRectangle(NodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); + DrawRectGlow(ctx, rect, NodeCornerRadius, node.IsSelected ? SelectionGlowColor : (_isLight ? NodeGlowColorL : NodeGlowColor)); + ctx.DrawRectangle(_isLight ? NodeFillL : 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; + var ft = MakeText(node.Name, NodeFontSize, _isLight ? NodeTextBrushL : NodeTextBrush); + double tx = node.X + (rw - ft.Width) / 2; + double ty = node.Y + (NodeHeaderHeight - ft.Height) / 2; ctx.DrawText(ft, new Point(tx, ty)); + // ── "▶ Entry Point" badge — left-aligned in the lower portion of the header ── + if (isEntryNode) + { + 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)); + } + } + // ── 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(ResizeHandleBrush, null, + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - offset + dotR, by - dotR), dotR, dotR); - ctx.DrawEllipse(ResizeHandleBrush, null, - new Point(bx - dotR, by - offset + dotR), dotR, dotR); + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, + new Point(bx - dotR, by - offset + dotR), dotR, dotR); } } @@ -1309,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 ? HookToggleActiveBg : 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, 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)); } @@ -1324,25 +2397,25 @@ private void RenderNodes(DrawingContext ctx) // and can therefore be folded into the parent's hook row. if (_canInlineNodes.Contains(node)) { - var minRect = InlineMinimizeButtonRect(node); - ctx.DrawRectangle(MinimizeBtnBg, null, minRect, 3.0, 3.0); - var minFt = MakeText("\u229f", HookFontSize, 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 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; 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; // Divider line separating header from content rows - var dividerPen = new Pen(HookDividerBrush, 1.0); + 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; @@ -1354,7 +2427,7 @@ private void RenderNodes(DrawingContext ctx) double rowMidY = node.Y + NodeHeaderHeight + HookRowHeight / 2.0; // Subtle tinted background for readability - ctx.DrawRectangle(ParamValueBg, null, + ctx.DrawRectangle(_isLight ? ParamValueBgL : ParamValueBg, null, new Rect(node.X + 1, node.Y + NodeHeaderHeight, rw - 2, HookRowHeight)); const double textPad = 6.0; @@ -1362,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, 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)); @@ -1376,8 +2449,8 @@ private void RenderNodes(DrawingContext ctx) if (hasHooks) { double sepY = node.Y + NodeHeaderHeight + HookRowHeight; - ctx.DrawLine(new Pen(HookDividerBrush, 0.5), - new Point(node.X + 1, sepY), + ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), + new Point(node.X + 1, sepY), new Point(node.X + rw - 1, sepY)); } } @@ -1389,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; @@ -1404,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 ? HookUnsatisfiedBrush - : (conn || hasInlined) ? HookConnectedBrush - : 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); @@ -1424,12 +2501,12 @@ private void RenderNodes(DrawingContext ctx) string hookLabel = hasInlined && inlinedParam is not null ? $"{hook.Name}: {(string.IsNullOrEmpty(inlinedParam.ParameterValueRepresentation) ? "(no value)" : inlinedParam.ParameterValueRepresentation)}" : hook.Name; - IBrush hookTextBrush = unsatisfied ? HookTextUnsatisfiedBrush - : hasInlined ? ParamValueTextBrush - : conn ? HookTextConnBrush - : HookTextDimBrush; - var hookFt = MakeText(hookLabel, HookFontSize, hookTextBrush); - double maxW = rw - textPad * 2 - HookDotRadius * 2; + 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; 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)); @@ -1438,8 +2515,8 @@ private void RenderNodes(DrawingContext ctx) if (i < hooks.Count - 1) { double sepY = node.Y + NodeHeaderHeight + (rowOffset + i + 1) * HookRowHeight; - ctx.DrawLine(new Pen(HookDividerBrush, 0.5), - new Point(node.X + 1, sepY), + ctx.DrawLine(new Pen(_isLight ? HookDividerBrushL : HookDividerBrush, 0.5), + new Point(node.X + 1, sepY), new Point(node.X + rw - 1, sepY)); } } @@ -1456,36 +2533,38 @@ 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 : GhostNodeBorderBrush; - var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash); + var borderBrush = ghost.IsSelected ? GhostNodeSelBrush : (_isLight ? GhostNodeBorderBrushL : GhostNodeBorderBrush); + var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash); - DrawRectShadow(ctx, rect, NodeCornerRadius); - ctx.DrawRectangle(GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); + DrawRectGlow(ctx, rect, NodeCornerRadius, ghost.IsSelected ? SelectionGlowColor : (_isLight ? GhostGlowColorL : GhostGlowColor)); + ctx.DrawRectangle(_isLight ? GhostNodeFillL : GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); // Ghost icon prefix ("⊙ ") to distinguish from real nodes at a glance. var labelText = "\u2299 " + ghost.Name; - var ft = MakeText(labelText, NodeFontSize, NodeTextBrush); - var tx = ghost.X + (rw - ft.Width) / 2; + var ft = MakeText(labelText, NodeFontSize, _isLight ? NodeTextBrushL : NodeTextBrush); + var tx = ghost.X + (rw - ft.Width) / 2; var ty = ghost.Y + (NodeHeaderHeight - ft.Height) / 2; using (ctx.PushClip(new Rect(ghost.X + 4, ghost.Y, rw - 8, NodeHeaderHeight))) + { ctx.DrawText(ft, new Point(tx, ty)); + } // Resize grip dots (bottom-right corner). { double dotR = 2.0; - double bx = ghost.X + rw; - double by = ghost.Y + rh; + double bx = ghost.X + rw; + double by = ghost.Y + rh; for (int d = 0; d < 3; d++) { double offset = 4.0 + d * 4.0; - ctx.DrawEllipse(ResizeHandleBrush, null, + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, new Point(bx - offset + dotR, by - dotR), dotR, dotR); - ctx.DrawEllipse(ResizeHandleBrush, null, - new Point(bx - dotR, by - offset + dotR), dotR, dotR); + ctx.DrawEllipse(_isLight ? ResizeHandleBrushL : ResizeHandleBrush, null, + new Point(bx - dotR, by - offset + dotR), dotR, dotR); } } } @@ -1495,16 +2574,16 @@ 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 : NodeBorderBrush, NodeBorderThickness); + var r = StartViewModel.Radius; + var border = new Pen(start.IsSelected ? NodeSelBrush : (_isLight ? StartBorderBrushL : NodeBorderBrush), NodeBorderThickness); - DrawEllipseShadow(ctx, center, r, r); + DrawEllipseGlow(ctx, center, r, r, start.IsSelected ? SelectionGlowColor : (_isLight ? StartGlowColorL : StartGlowColor)); ctx.DrawEllipse(fill, border, center, r, r); // Label below the circle - bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; + var isLight = _isLight; var ft = MakeText(start.Name, StartFontSize, isLight ? StartTextBrushLight : StartTextBrush); var lx = start.X + (start.Diameter - ft.Width) / 2; var ly = start.Y + start.Diameter + 3; @@ -1512,24 +2591,62 @@ private void RenderStarts(DrawingContext ctx) } } - /// Draws a three-layer simulated drop shadow for a rounded rectangle. - private static void DrawRectShadow(DrawingContext ctx, Rect rect, double cornerRadius) + /// + /// Updates the zoom bar's colours to match the current light/dark theme. + private void UpdateZoomBarColors(bool isLight) { - ctx.DrawRectangle(ShadowBrush1, null, rect.Translate(new Vector(6, 6)), cornerRadius, cornerRadius); - ctx.DrawRectangle(ShadowBrush2, null, rect.Translate(new Vector(4, 4)), cornerRadius, cornerRadius); - ctx.DrawRectangle(ShadowBrush3, null, rect.Translate(new Vector(2, 2)), cornerRadius, cornerRadius); + _zoomBarIsLight = 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)); + _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 + { + // 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)); + } } - /// Draws a three-layer simulated drop shadow for an ellipse. - private static void DrawEllipseShadow(DrawingContext ctx, Point center, double rx, double ry) + /// + /// Draws a neon glow halo around a rounded rectangle using 5 outward-expanding semi-transparent + /// layers of , fading from fully transparent at the outer edge to + /// relatively vivid just inside the object border. + /// + private static void DrawRectGlow(DrawingContext ctx, Rect rect, double cornerRadius, Color glowColor) { - ctx.DrawEllipse(ShadowBrush1, null, center + new Vector(6, 6), rx, ry); - ctx.DrawEllipse(ShadowBrush2, null, center + new Vector(4, 4), rx, ry); - ctx.DrawEllipse(ShadowBrush3, null, center + new Vector(2, 2), rx, ry); + 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(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); + } + + /// + /// Draws a neon glow halo around an ellipse using 5 outward-expanding semi-transparent layers of + /// , fading from fully transparent at the outer edge inward. + /// + 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(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); } private static FormattedText MakeText(string text, double size, IBrush foreground) => - new FormattedText( + new( text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, @@ -1541,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) @@ -1570,7 +2690,9 @@ protected override void OnKeyDown(KeyEventArgs e) else if (e.Key == Key.F2 && _vm?.SelectedElement is not null) { var sel = _vm.SelectedElement; - if (sel is NodeViewModel or StartViewModel) + if (sel is NodeViewModel or StartViewModel + or FunctionTemplateViewModel or FunctionInstanceViewModel + or FunctionParameterViewModel) { BeginNameEdit(sel); e.Handled = true; @@ -1581,6 +2703,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 ─────────────────────────────────────────────────── @@ -1625,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)); } } @@ -1645,6 +2773,71 @@ 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) @@ -1672,11 +2865,19 @@ 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 + 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; @@ -1689,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(); @@ -1711,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; @@ -1748,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(); } } @@ -1773,7 +2974,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; } @@ -1813,6 +3024,44 @@ 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; + } + + // ── Double-click on a function parameter: begin inline rename ── + var fpHit = HitTest(mpos, testComments: false) as FunctionParameterViewModel; + if (fpHit is not null) + { + _vm.SelectElementCommand.Execute(fpHit); + BeginNameEdit(fpHit); + 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. @@ -1823,20 +3072,23 @@ 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; } - // Begin link-creation drag from a node or start. + // Begin link-creation drag from a node, start, or function instance (via its FunctionParameterHooks). // Comment blocks are not valid link origins. - if (hit is NodeViewModel or StartViewModel) + 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(); @@ -1849,11 +3101,11 @@ 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) + 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 @@ -1887,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); } @@ -1907,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); } @@ -1929,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); } @@ -1945,9 +3197,13 @@ 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. + 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) { @@ -1963,11 +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; @@ -2020,10 +3294,13 @@ 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); } } else @@ -2031,16 +3308,29 @@ 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); } InvalidateAndMeasure(); + TryAutoScrollForDrag(svPos); e.Handled = true; } + /// + /// Suppress Avalonia's automatic context-menu opening on right-click release. + /// The canvas shows the context menu manually in + /// only when the pointer has moved less than the drag threshold, so we must + /// prevent the framework from opening it independently. + /// + private void SuppressContextRequested(object? sender, ContextRequestedEventArgs e) + => e.Handled = true; + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); @@ -2050,11 +3340,10 @@ 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 - && (_rightClickElement is not null || _rightClickLink is not null)) + if (Math.Sqrt(rdx * rdx + rdy * rdy) < 3.0) { _linkOrigin = null; e.Pointer.Capture(null); @@ -2075,11 +3364,18 @@ 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); + else if (hit is FunctionParameterViewModel fpDest + && !ReferenceEquals(fpDest, origin)) + _ = _vm.CreateLinkAsync(origin, fpDest); } e.Handled = true; @@ -2090,12 +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(); + _resizing.CommitResize(); _resizing = null; e.Pointer.Capture(null); Cursor = Cursor.Default; @@ -2165,6 +3456,36 @@ 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; + } + } + 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; } @@ -2176,27 +3497,93 @@ 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(); + 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 { - 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(); + _dragging?.CommitMove(); } _dragging = null; + _autoScrollTimer.Stop(); e.Pointer.Capture(null); InvalidateAndMeasure(); e.Handled = true; @@ -2209,7 +3596,38 @@ 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 spawnPt = ToCanvasPos(_rightClickPressPos); + + var addStartItem = new MenuItem { Header = "Add Start…" }; + addStartItem.Click += (_, _) => _vm.AddStartAt(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addStartItem); + + var addModuleItem = new MenuItem { Header = "Add Module…" }; + addModuleItem.Click += (_, _) => _ = _vm.AddModuleAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addModuleItem); + + var addCommentItem = new MenuItem { Header = "Add Comment" }; + addCommentItem.Click += (_, _) => _vm.AddCommentBlockAt(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addCommentItem); + + var addFtItem = new MenuItem { Header = "Add Function Template…" }; + addFtItem.Click += (_, _) => _ = _vm.AddFunctionTemplateAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addFtItem); + + var addFiItem = new MenuItem { Header = "Add Function Instance…" }; + addFiItem.Click += (_, _) => _ = _vm.AddFunctionInstanceAtAsync(spawnPt.X, spawnPt.Y); + bgMenu.Items.Add(addFiItem); + + ContextMenu = bgMenu; + ContextMenu.Open(this); + return; + } var vm = _vm; // capture for closure @@ -2256,6 +3674,68 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) interBoundaryItem.Click += (_, _) => _ = 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) + { + var spawnPt2 = ToCanvasPos(_rightClickPressPos); + var addFpItem = new MenuItem { Header = "Add Function Parameter" }; + addFpItem.Click += (_, _) => + _ = vm.AddFunctionParameterFromHookAsync( + capturedNode, capturedHook, spawnPt2.X, spawnPt2.Y); + menu.Items.Add(addFpItem); + } + + menu.Items.Add(new Separator()); + } + + // ── FunctionInstance hook right-click items ──────────────────────────── + if (_rightClickFiHookHit is { } fiHookEntry) + { + var capturedFiOrigin = fiHookEntry.Fi; + var capturedFpHook = fiHookEntry.Hook; + + var allBoundariesItem = new MenuItem { Header = "Link to node in another boundary…" }; + allBoundariesItem.Click += (_, _) => + _ = vm.CreateInterBoundaryLinkAsync(capturedFiOrigin, capturedFpHook); + menu.Items.Add(allBoundariesItem); + 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) + { + var capturedReorderMl = reorderMl; + var reorderLinkItem = new MenuItem { Header = "Reorder Destinations…" }; + reorderLinkItem.Click += (_, _) => _ = vm.ReorderLinkDestinationsAsync(capturedReorderMl); + menu.Items.Add(reorderLinkItem); menu.Items.Add(new Separator()); } @@ -2300,20 +3780,52 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) menu.Items.Add(new Separator()); - bool alreadyVar = vm.IsNodeInVariables(paramNode); - var varHeader = alreadyVar - ? "Remove from Model System Variables" - : "Add to Model System Variables"; - var varItem = new MenuItem { Header = varHeader }; - varItem.Click += (_, _) => + if (vm.IsInsideFunctionTemplate) { - if (vm.IsNodeInVariables(paramNode)) - _ = vm.RemoveNodeFromVariablesAsync(paramNode); - else - _ = vm.AddNodeToVariablesAsync(paramNode); - }; - menu.Items.Add(varItem); - menu.Items.Add(new Separator()); + // Inside a FunctionTemplate: only offer local variables for eligible node types. + if (FunctionTemplate.IsValidLocalVariableNode(paramNode.UnderlyingNode, out _)) + { + bool alreadyLocalVar = vm.IsNodeInLocalVariables(paramNode); + var localVarHeader = alreadyLocalVar + ? "Remove from Local Variables" + : "Add as Local Variable"; + var localVarItem = new MenuItem { Header = localVarHeader }; + localVarItem.Click += (_, _) => + { + if (vm.IsNodeInLocalVariables(paramNode)) + { + _ = vm.RemoveNodeFromLocalVariablesAsync(paramNode); + } + else + { + _ = vm.AddNodeToLocalVariablesAsync(paramNode); + } + }; + menu.Items.Add(localVarItem); + menu.Items.Add(new Separator()); + } + } + else + { + bool alreadyVar = vm.IsNodeInVariables(paramNode); + var varHeader = alreadyVar + ? "Remove from Model System Variables" + : "Add to Model System Variables"; + var varItem = new MenuItem { Header = varHeader }; + varItem.Click += (_, _) => + { + if (vm.IsNodeInVariables(paramNode)) + { + _ = vm.RemoveNodeFromVariablesAsync(paramNode); + } + else + { + _ = vm.AddNodeToVariablesAsync(paramNode); + } + }; + menu.Items.Add(varItem); + menu.Items.Add(new Separator()); + } } // ── IFunction → Create linked ExecuteWithContext ─────────────── @@ -2390,14 +3902,109 @@ 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); + } + + // ── "Add Function Parameter" — when we're inside a function template ───── + if (_vm.IsInsideFunctionTemplate && element is FunctionParameterViewModel) + { + // Right-clicking an existing FunctionParameter: offer rename or remove. + var fpvm = (FunctionParameterViewModel)element; + var template = _vm.CurrentFunctionTemplate?.UnderlyingTemplate; + + // ── Local-variable toggle (only for IFunction parameters) ── + if (template is not null + && FunctionTemplate.IsValidLocalVariableNode(fpvm.UnderlyingParameter, out _)) + { + bool isAlreadyVar = template.LocalVariables.Contains(fpvm.UnderlyingParameter); + var localVarHeader = isAlreadyVar + ? "Remove from Local Variables" + : "Add as Local Variable"; + var localVarItem = new MenuItem { Header = localVarHeader }; + localVarItem.Click += async (_, _) => + { + await vm.ToggleFunctionTemplateVariableAsync(fpvm.UnderlyingParameter); + InvalidateAndMeasure(); + }; + menu.Items.Add(new Separator()); + menu.Items.Add(localVarItem); + } + + var fpRenameItem = new MenuItem { Header = "Rename…" }; + fpRenameItem.Click += async (_, _) => + { + await vm.RenameFunctionParameterAsync(fpvm); + InvalidateAndMeasure(); + }; + + var removeItem = new MenuItem { Header = "Remove Function Parameter" }; + removeItem.Click += async (_, _) => + { + await vm.RemoveFunctionParameterAsync(fpvm.UnderlyingParameter); + InvalidateAndMeasure(); + }; + menu.Items.Add(new Separator()); + menu.Items.Add(fpRenameItem); + menu.Items.Add(removeItem); + } + // ── "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; ContextMenu.Open(this); } - // ── Resize handle hit-testing ───────────────────────────────────────── - // ── Inline parameter editor ──────────────────────────────────────────────── /// @@ -2413,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; } @@ -2423,6 +4032,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(); @@ -2430,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. @@ -2440,16 +4062,42 @@ 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 + // 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 { _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; } @@ -2473,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. @@ -2494,13 +4143,14 @@ private void CommitParamEdit() } // Save succeeded – close the editor. - _editingParamNode = null; - _inlineEditor.IsVisible = false; + UnsubscribeInlineEditorScroll(); + _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(); } @@ -2514,13 +4164,14 @@ private void CommitParamEdit() private void CancelParamEdit() { HideVarDropdown(); - _editingParamNode = null; - _inlineEditor.IsVisible = false; + UnsubscribeInlineEditorScroll(); + _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(); @@ -2601,13 +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), + _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) { @@ -2619,7 +4271,7 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) int start = i++; while (i < text.Length && text[i] != '"') i++; if (i < text.Length) i++; // consume closing quote - tokens.Add((text[start..i], ScriptStringBrush)); + tokens.Add((text[start..i], _isLight ? ScriptStringBrushL : ScriptStringBrush)); continue; } @@ -2628,7 +4280,7 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) { int start = i; while (i < text.Length && char.IsWhiteSpace(text[i])) i++; - tokens.Add((text[start..i], ParamValueTextBrush)); + tokens.Add((text[start..i], _isLight ? ParamValueTextBrushL : ParamValueTextBrush)); continue; } @@ -2640,10 +4292,10 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) var word = text[start..i]; IBrush brush = word switch { - "true" or "false" => ScriptKeywordBrush, - _ => knownNames.Contains(word) - ? ScriptVarKnownBrush - : ScriptVarUnknownBrush, + "true" or "false" => _isLight ? ScriptKeywordBrushL : ScriptKeywordBrush, + _ => knownNames.Contains(word) + ? (_isLight ? ScriptVarKnownBrushL : ScriptVarKnownBrush) + : (_isLight ? ScriptVarUnknownBrushL : ScriptVarUnknownBrush), }; tokens.Add((word, brush)); continue; @@ -2654,7 +4306,7 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) { int start = i; while (i < text.Length && (char.IsDigit(text[i]) || text[i] == '.')) i++; - tokens.Add((text[start..i], ScriptNumberBrush)); + tokens.Add((text[start..i], _isLight ? ScriptNumberBrushL : ScriptNumberBrush)); continue; } @@ -2672,7 +4324,7 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e) { i++; } - tokens.Add((text[start..i], ScriptOperatorBrush)); + tokens.Add((text[start..i], _isLight ? ScriptOperatorBrushL : ScriptOperatorBrush)); } } return tokens.ToArray(); @@ -2698,11 +4350,13 @@ 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 { - _scriptTokens = Array.Empty<(string, IBrush)>(); + _scriptTokens = []; _scriptOverlay.Tokens = _scriptTokens; } @@ -2713,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. @@ -2722,7 +4376,9 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) { char ch = text[tokenStart - 1]; if (char.IsWhiteSpace(ch) || IsExpressionSpecialChar(ch)) + { break; + } tokenStart--; } @@ -2738,8 +4394,10 @@ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e) bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light; var matches = _vm.ModelSystemVariables + .Concat(_vm.LocalVariables) .Where(v => v.Name.Contains(token, StringComparison.OrdinalIgnoreCase)) .Select(v => v.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) .Take(MaxVarDropdownItems) .ToList(); @@ -2750,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 @@ -2766,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 += (_, _) => @@ -2802,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; } } @@ -2821,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); + } } /// @@ -2833,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 @@ -2849,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(); @@ -2901,13 +4564,34 @@ 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 if (element is FunctionParameterViewModel fpvmEdit) + { + _nameEditorX = fpvmEdit.X; + _nameEditorY = fpvmEdit.Y; + _nameEditorW = fpvmEdit.Width; + _nameEditorH = FtHeaderHeight; + } else { return; } - _editingNameElement = element; - _nameEditor.Text = element.Name; + _editingNameElement = element; + _nameEditor.Text = element.Name; _nameEditor.IsVisible = true; InvalidateMeasure(); Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -2922,17 +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)) { - _ = element switch + CommandError? renameError = null; + bool ok = element switch { - NodeViewModel nvm => nvm.SetName(name, out _), + NodeViewModel nvm => nvm.SetName(name, out _), StartViewModel svm => svm.SetName(name, out _), - _ => true, + FunctionTemplateViewModel ftvm => ftvm.SetName(name, out renameError), + FunctionInstanceViewModel fivm => fivm.SetName(name, out renameError), + FunctionParameterViewModel fpvmC => fpvmC.SetName(name, out renameError), + _ => true, }; + if (!ok) + { + _vm?.ShowToast(renameError?.Message ?? "Failed to rename.", isError: true, durationMs: 4000); + } } InvalidateAndMeasure(); } @@ -2940,7 +4632,7 @@ private void CommitNameEdit() /// Discards the name edit without saving. private void CancelNameEdit() { - _editingNameElement = null; + _editingNameElement = null; _nameEditor.IsVisible = false; InvalidateAndMeasure(); Focus(); @@ -2964,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(); + } } /// @@ -2973,8 +4667,12 @@ 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 + or FunctionParameterViewModel) + { BeginNameEdit(_vm.SelectedElement); + } } // ── Inline comment editor helpers ──────────────────────────────────────── @@ -2982,18 +4680,20 @@ public void BeginNameEditForSelected() 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 @@ -3015,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(); @@ -3025,7 +4725,7 @@ private void CommitCommentEdit() /// Discards the comment edit without saving. private void CancelCommentEdit() { - _editingCommentBlock = null; + _editingCommentBlock = null; _commentEditor.IsVisible = false; InvalidateAndMeasure(); Focus(); @@ -3050,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(); + } } /// @@ -3065,39 +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) + + ICanvasElement? CheckHandle(ObservableCollection collection) where T : ICanvasElement { - var handle = new Rect( - ghost.X + ghost.Width - ResizeHandleSize, - ghost.Y + ghost.Height - ResizeHandleSize, - ResizeHandleSize, - ResizeHandleSize); - if (handle.Contains(pos)) - return ghost; + 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; } - 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 ───────────────────────────────────── @@ -3112,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; } @@ -3161,7 +4857,9 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) foreach (var node in _canInlineNodes) { if (InlineMinimizeButtonRect(node).Contains(pos)) + { return node; + } } return null; } @@ -3170,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) { @@ -3184,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); @@ -3200,41 +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; + foreach (var element in collection) + { + if (element.IsPointWithin(pos)) + return element; + } + return null; } - // Ghost nodes - foreach (var ghost in _vm.GhostNodes) - { - if (new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height).Contains(pos)) - return ghost; - } + 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); - // 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 ──────────────────────────────────────────────── @@ -3246,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 @@ -3254,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)); /// @@ -3276,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/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)); } } diff --git a/src/XTMF2.GUI/MainWindow.axaml b/src/XTMF2.GUI/MainWindow.axaml index 3acfc11..2dea167 100644 --- a/src/XTMF2.GUI/MainWindow.axaml +++ b/src/XTMF2.GUI/MainWindow.axaml @@ -12,6 +12,7 @@ x:CompileBindings="True" Width="1200" Height="700" + Background="{DynamicResource DlgBg}" KeyDown="Window_KeyDown"> 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 new file mode 100644 index 0000000..a5ecab1 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs @@ -0,0 +1,229 @@ +/* + 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 objects derived from the referenced template. + /// Kept in sync with . + /// + public ObservableCollection FunctionParameters { 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 FunctionParameters so hook rows stay current. + SyncFunctionParameters(); + ((INotifyCollectionChanged)instance.Template.FunctionParameters).CollectionChanged += OnFunctionParametersChanged; + } + + private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(FunctionInstance.Name): + Name = UnderlyingInstance.Name; + break; + case nameof(FunctionInstance.Hooks): + // A FunctionParameter was renamed (or added/removed); re-sync the hook label list + // and notify the canvas to redraw FI hook rows. + SyncFunctionParameters(); + OnPropertyChanged(nameof(FunctionParameters)); + 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 OnFunctionParametersChanged(object? sender, NotifyCollectionChangedEventArgs e) + => SyncFunctionParameters(); + + private void SyncFunctionParameters() + { + FunctionParameters.Clear(); + foreach (var fp in UnderlyingInstance.Template.FunctionParameters) + FunctionParameters.Add(fp); + } + + // ── 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); + } + + /// + /// 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; + 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.FunctionParameters).CollectionChanged -= OnFunctionParametersChanged; + } +} diff --git a/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs new file mode 100644 index 0000000..111d0eb --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/FunctionParameterViewModel.cs @@ -0,0 +1,205 @@ +/* + Copyright 2026 University of Toronto + + This file is part of XTMF2. + + XTMF2 is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + XTMF2 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with XTMF2. If not, see . +*/ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +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. +/// FunctionParameters are rendered inside their owning 's +/// boundary as special orange/red nodes +/// with a "Parameter:" prefix in the header. +/// +public sealed partial class FunctionParameterViewModel : ObservableObject, ICanvasElement +{ + /// The underlying model object. + public FunctionParameter UnderlyingParameter { 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)UnderlyingParameter.Location.X; + + /// + public double Y => _previewY ?? (double)UnderlyingParameter.Location.Y; + + /// Rendered width; defaults to 180 when the stored value is 0. + public double Width => _previewW ?? (UnderlyingParameter.Location.Width is 0 ? 180.0 : (double)UnderlyingParameter.Location.Width); + + /// Rendered height; defaults to 40 when the stored value is 0. + public double Height => _previewH ?? (UnderlyingParameter.Location.Height is 0 ? 40.0 : (double)UnderlyingParameter.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; + + /// + /// Display name of the parameter type, with generic arguments expanded + /// (e.g. IModule<int> instead of IModule`1). + /// + public string TypeName => FormatTypeName(UnderlyingParameter.Type); + + private static string FormatTypeName(Type? type) + { + if (type is null) return string.Empty; + if (!type.IsGenericType) return type.Name; + int tick = type.Name.IndexOf('`'); + var baseName = tick < 0 ? type.Name : type.Name[..tick]; + var args = string.Join(", ", type.GetGenericArguments().Select(FormatTypeName)); + return $"{baseName}<{args}>"; + } + + public FunctionParameterViewModel(FunctionParameter parameter, ModelSystemSession session, User user) + { + UnderlyingParameter = parameter; + _session = session; + _user = user; + _name = parameter.Name; + + ((INotifyPropertyChanged)parameter).PropertyChanged += OnModelPropertyChanged; + } + + private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(FunctionParameter.Name): + Name = UnderlyingParameter.Name; + break; + case nameof(FunctionParameter.Location): + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + break; + case nameof(FunctionParameter.Type): + OnPropertyChanged(nameof(TypeName)); + break; + } + } + + /// + /// Renames this via the session (with undo). + /// + public bool SetName(string newName, [NotNullWhen(false)] out CommandError? error) + => _session.RenameFunctionParameter(_user, UnderlyingParameter.Template, UnderlyingParameter, newName, out error); + + // ── Drag / resize support ───────────────────────────────────────────── + + /// + /// Updates the visual position without persisting (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 preview position and clears it. 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; + var loc = UnderlyingParameter.Location; + _session.SetNodeLocation(_user, UnderlyingParameter, + 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. + /// + public void ResizeToPreview(double w, double h) + { + _previewW = Math.Max(120.0, w); + _previewH = Math.Max(28.0, h); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + } + + /// + /// Commits the preview size and clears it. 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 = UnderlyingParameter.Location; + _session.SetNodeLocation(_user, UnderlyingParameter, + new Rectangle(loc.X, loc.Y, (float)w, (float)h), out _); + } + + /// Detaches model event subscriptions (call before discarding this VM). + public void Detach() + { + ((INotifyPropertyChanged)UnderlyingParameter).PropertyChanged -= OnModelPropertyChanged; + } +} diff --git a/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs new file mode 100644 index 0000000..cc99968 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/FunctionTemplateViewModel.cs @@ -0,0 +1,247 @@ +/* + 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; + + // ── FunctionParameter mirrors (synced from model) ───────────────────── + /// + /// Live list of objects belonging to this template. + /// Kept in sync with . + /// + public ObservableCollection FunctionParameters { 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 FunctionParameters collection. + SyncFunctionParameters(); + ((INotifyCollectionChanged)template.FunctionParameters).CollectionChanged += OnFunctionParametersChanged; + } + + 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 OnFunctionParametersChanged(object? sender, NotifyCollectionChangedEventArgs e) + => SyncFunctionParameters(); + + private void SyncFunctionParameters() + { + FunctionParameters.Clear(); + foreach (var fp in UnderlyingTemplate.FunctionParameters) + FunctionParameters.Add(fp); + } + + // ── 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); + } + + /// + /// 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). + /// + 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.FunctionParameters).CollectionChanged -= OnFunctionParametersChanged; + } +} 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/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/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 e3d8a07..1713e06 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,12 +103,25 @@ 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(); // ── 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; @@ -126,12 +142,30 @@ 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 wrappers around of the + /// currently active function template. Only populated while + /// is true. + /// + public ObservableCollection FunctionParameterVMs { get; } = new(); + /// Observable view-models for the model system's variable list. public ObservableCollection ModelSystemVariables { get; } = new(); - /// Text typed into the variables filter box; filters . + /// Observable view-models for the current FunctionTemplate's local variable list. + /// Empty when not inside a FunctionTemplate. + public ObservableCollection LocalVariables { get; } = new(); + + /// Text typed into the variables filter box; filters and . [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredModelSystemVariables))] + [NotifyPropertyChangedFor(nameof(FilteredLocalVariables))] private string _variableFilter = string.Empty; /// @@ -154,6 +188,30 @@ public IEnumerable FilteredModelSystemVariables } } + /// + /// Sorted (by name) and filtered (by ) view of + /// for the current FunctionTemplate. + /// + public IEnumerable FilteredLocalVariables + { + get + { + var q = LocalVariables.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(VariableFilter)) + { + var f = VariableFilter.Trim(); + q = q.Where(v => v.Name.Contains(f, StringComparison.OrdinalIgnoreCase)); + } + return q.OrderBy(v => v.Name, StringComparer.OrdinalIgnoreCase); + } + } + + /// True when local FunctionTemplate variables are available (drives section visibility). + public bool HasLocalVariables => LocalVariables.Count > 0; + + /// True when neither global nor local variables are defined (drives the empty-state label). + public bool HasNoVariablesAtAll => ModelSystemVariables.Count == 0 && LocalVariables.Count == 0; + /// The currently selected link, if any. Mutually exclusive with . [ObservableProperty] [NotifyPropertyChangedFor(nameof(NothingSelected))] @@ -247,9 +305,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 list of the template referenced by the currently + /// selected function instance, or an empty collection. + /// + public System.Collections.Generic.IEnumerable SelectedFunctionInstanceFunctionParameters + => (SelectedElement as FunctionInstanceViewModel)?.FunctionParameters + ?? System.Linq.Enumerable.Empty(); + + /// + /// true when the selected function instance's template has no function parameters — + /// drives the "(none)" hint text in the properties panel. + /// + public bool SelectedFunctionInstanceHasNoFunctionParameters + => SelectedElement is not FunctionInstanceViewModel fi || fi.FunctionParameters.Count == 0; + + /// + /// The list of the currently selected function template, + /// or an empty list when nothing (or a non-template element) is selected. + /// + public IEnumerable SelectedFunctionTemplateFunctionParameters + => (SelectedElement as FunctionTemplateViewModel)?.FunctionParameters + ?? System.Linq.Enumerable.Empty(); + + /// + /// true when the selected function template has no function parameters — + /// drives the "(none)" hint text in the properties panel. + /// + public bool SelectedFunctionTemplateHasNoFunctionParameters + => SelectedElement is not FunctionTemplateViewModel ft || ft.FunctionParameters.Count == 0; + /// All module types currently registered in the runtime. public System.Collections.ObjectModel.ReadOnlyObservableCollection AvailableModuleTypes => Session.LoadedModuleTypes; @@ -290,6 +393,13 @@ partial void OnSelectedElementChanged(ICanvasElement? value) OnPropertyChanged(nameof(SelectedElementIsComment)); OnPropertyChanged(nameof(SelectedElementIsNotComment)); OnPropertyChanged(nameof(SelectedElementIsParameter)); + OnPropertyChanged(nameof(SelectedElementIsFunctionTemplate)); + OnPropertyChanged(nameof(SelectedFunctionTemplateFunctionParameters)); + OnPropertyChanged(nameof(SelectedFunctionTemplateHasNoFunctionParameters)); + OnPropertyChanged(nameof(SelectedElementIsFunctionInstance)); + OnPropertyChanged(nameof(SelectedFunctionInstanceTemplateName)); + OnPropertyChanged(nameof(SelectedFunctionInstanceFunctionParameters)); + OnPropertyChanged(nameof(SelectedFunctionInstanceHasNoFunctionParameters)); SelectedElementParameterValue = value is NodeViewModel pnvm && pnvm.IsParameterNode ? pnvm.ParameterValueRepresentation @@ -371,9 +481,15 @@ 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)); + // Populate FunctionParameterVMs when inside a function template's InternalModules. + if (_currentFunctionTemplate is not null) + foreach (var fp in _currentFunctionTemplate.UnderlyingTemplate.FunctionParameters) + FunctionParameterVMs.Add(new FunctionParameterViewModel(fp, 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 +505,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 +520,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,12 +552,33 @@ 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(); + foreach (var fp in FunctionParameterVMs) fp.Detach(); + FunctionParameterVMs.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)) + { + ((INotifyCollectionChanged)_currentFunctionTemplate.UnderlyingTemplate.FunctionParameters).CollectionChanged + -= OnFunctionParametersChanged; + ((INotifyCollectionChanged)_currentFunctionTemplate.UnderlyingTemplate.LocalVariables).CollectionChanged + -= OnLocalVariablesChanged; + _currentFunctionTemplate = null; + SyncLocalVariables(null); + OnPropertyChanged(nameof(IsInsideFunctionTemplate)); + ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + } + NavigateUpCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigateUp)); SubscribeToBoundary(_currentBoundary); BuildFromBoundary(_currentBoundary); RebuildBoundaryNavItems(); @@ -477,7 +618,12 @@ private void OnModulesChanged(object? sender, NotifyCollectionChangedEventArgs e { if (e.NewItems is not null) foreach (Node n in e.NewItems) - Nodes.Add(new NodeViewModel(n, Session, User)); + { + var nvm = new NodeViewModel(n, Session, User); + nvm.ShowHooks = true; // expand hooks by default so the user can immediately see all connections + Nodes.Add(nvm); + SelectElement(nvm); + } if (e.OldItems is not null) foreach (Node n in e.OldItems) @@ -491,7 +637,11 @@ private void OnStartsChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null) foreach (Start s in e.NewItems) - Starts.Add(new StartViewModel(s, Session, User)); + { + var svm = new StartViewModel(s, Session, User); + Starts.Add(svm); + SelectElement(svm); + } if (e.OldItems is not null) foreach (Start s in e.OldItems) @@ -524,7 +674,11 @@ private void OnCommentBlocksChanged(object? sender, NotifyCollectionChangedEvent { if (e.NewItems is not null) foreach (CommentBlock cb in e.NewItems) - CommentBlocks.Add(new CommentBlockViewModel(cb, Session, User)); + { + var cvm = new CommentBlockViewModel(cb, Session, User); + CommentBlocks.Add(cvm); + SelectElement(cvm); + } if (e.OldItems is not null) foreach (CommentBlock cb in e.OldItems) @@ -548,6 +702,72 @@ 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) + { + var ftvm = new FunctionTemplateViewModel(ft, Session, User); + FunctionTemplates.Add(ftvm); + SelectElement(ftvm); + } + + 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) + { + var fivm = new FunctionInstanceViewModel(fi, Session, User); + FunctionInstances.Add(fivm); + SelectElement(fivm); + } + + 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 OnFunctionParametersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null) + foreach (FunctionParameter fp in e.NewItems) + { + var fpvm = new FunctionParameterViewModel(fp, Session, User); + FunctionParameterVMs.Add(fpvm); + SelectElement(fpvm); + } + + if (e.OldItems is not null) + foreach (FunctionParameter fp in e.OldItems) + { + var vm = FunctionParameterVMs.FirstOrDefault(v => v.UnderlyingParameter == fp); + if (vm is not null) + { + vm.Detach(); + FunctionParameterVMs.Remove(vm); + } + } + } + private void TryAddLinkViewModel(Link link) { var originElement = ResolveElement(link.Origin); @@ -604,6 +824,10 @@ 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); + if (node is FunctionParameter fp) + return FunctionParameterVMs.FirstOrDefault(fpvm => fpvm.UnderlyingParameter == fp); 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,16 +1119,142 @@ 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, + FunctionInstanceViewModel fivm => fivm.UnderlyingInstance, + _ => 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); + } + + /// + /// Creates a link whose destination is a inside the current + /// . The hook on must be + /// type-compatible with 's declared parameter type. + /// + public async Task CreateLinkAsync(ICanvasElement originElement, FunctionParameterViewModel destFpVm) + { + if (ParentWindow is null || _currentFunctionTemplate is null) return; + + Node? originNode = originElement switch + { + NodeViewModel nvm => nvm.UnderlyingNode, + StartViewModel svm => svm.UnderlyingStart, + FunctionInstanceViewModel fivm => fivm.UnderlyingInstance, + _ => null + }; + if (originNode is null) return; + + var fp = destFpVm.UnderlyingParameter; + var destType = fp.Type; + if (destType is null) + { + await ShowError("No Type", + new CommandError($"FunctionParameter '{fp.Name}' has no type assigned and cannot be used as a link destination.")); + 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 FunctionParameter 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 parameter '{fp.Name}':"); + await dialog.ShowDialog(ParentWindow); + if (dialog.WasCancelled || dialog.SelectedHook is null) return; + selectedHook = dialog.SelectedHook; + } + + if (!Session.AddLink(User, originNode, selectedHook, fp, out _, out var error)) + await ShowError("Create Link Failed", error); + } + public async Task CreateLinkAsync(ICanvasElement originElement, NodeViewModel destVm) { if (ParentWindow is null) return; - // Resolve the underlying origin node (Start is also a Node). + // Resolve the underlying origin node (Start is also a Node; FunctionInstance is also a Node). Node? originNode = originElement switch { - NodeViewModel nvm => nvm.UnderlyingNode, - StartViewModel svm => svm.UnderlyingStart, - _ => null + NodeViewModel nvm => nvm.UnderlyingNode, + StartViewModel svm => svm.UnderlyingStart, + FunctionInstanceViewModel fivm => fivm.UnderlyingInstance, + _ => null }; if (originNode is null) return; @@ -1104,6 +1454,24 @@ public async Task CreateInterBoundaryLinkAsync(NodeViewModel originNode, NodeHoo // On success the boundary CollectionChanged fires and TryAddLinkViewModel wires up the new link. } + /// + /// Opens the inter-boundary node picker for a origin + /// using one of its s, then creates the link. + /// + public async Task CreateInterBoundaryLinkAsync(FunctionInstanceViewModel fi, FunctionParameterHook hook) + { + if (ParentWindow is null) return; + + var allBoundaries = GetAllBoundaries(GlobalBoundary); + var dialog = new Views.InterBoundaryLinkDialog(allBoundaries, hook, fi.Name); + await dialog.ShowDialog(ParentWindow); + + if (dialog.WasCancelled || dialog.ChosenNode is null) return; + + if (!Session.AddLink(User, fi.UnderlyingInstance, hook, dialog.ChosenNode, out _, out var error)) + await ShowError("Create Link Failed", error); + } + /// /// Apply the value in to the /// selected BasicParameter / ScriptedParameter node. @@ -1180,6 +1548,10 @@ public async Task EditParameterNodeAsync(NodeViewModel nvm) public bool IsNodeInVariables(NodeViewModel nvm) => Session.ModelSystem.Variables.Contains(nvm.UnderlyingNode); + /// Returns true when is in the current FunctionTemplate's local variable list. + public bool IsNodeInLocalVariables(NodeViewModel nvm) => + _currentFunctionTemplate?.UnderlyingTemplate.LocalVariables.Contains(nvm.UnderlyingNode) ?? false; + /// True when the model system variable list is empty (drives the empty-state label). public bool HasNoModelSystemVariables => ModelSystemVariables.Count == 0; @@ -1203,6 +1575,30 @@ public async Task RemoveNodeFromVariablesAsync(NodeViewModel nvm) await ShowError("Remove Variable Failed", error); } + /// + /// Adds the given node to the current FunctionTemplate's local variable list. + /// Called from the canvas context menu when inside a FunctionTemplate. + /// + public async Task AddNodeToLocalVariablesAsync(NodeViewModel nvm) + { + if (_currentFunctionTemplate is null) return; + if (!Session.AddFunctionTemplateVariable(User, _currentFunctionTemplate.UnderlyingTemplate, + nvm.UnderlyingNode, out var error)) + await ShowError("Add Local Variable Failed", error); + } + + /// + /// Removes the given node from the current FunctionTemplate's local variable list. + /// Called from the canvas context menu when inside a FunctionTemplate. + /// + public async Task RemoveNodeFromLocalVariablesAsync(NodeViewModel nvm) + { + if (_currentFunctionTemplate is null) return; + if (!Session.RemoveFunctionTemplateVariable(User, _currentFunctionTemplate.UnderlyingTemplate, + nvm.UnderlyingNode, out var error)) + await ShowError("Remove Local Variable Failed", error); + } + /// /// Removes the given variable entry from the model system's variable list. /// Bound to the "Remove" button in the variables panel. @@ -1214,6 +1610,19 @@ private async Task RemoveVariableNode(ModelSystemVariableViewModel varVm) await ShowError("Remove Variable Failed", error); } + /// + /// Removes the given node from the current FunctionTemplate's local variable list. + /// Bound to the "Remove" button in the local variables section. + /// + [RelayCommand] + private async Task RemoveLocalVariableNode(ModelSystemVariableViewModel varVm) + { + if (_currentFunctionTemplate is null) return; + if (!Session.RemoveFunctionTemplateVariable(User, _currentFunctionTemplate.UnderlyingTemplate, + varVm.UnderlyingNode, out var error)) + await ShowError("Remove Local Variable Failed", error); + } + /// /// Navigates to the boundary containing the variable's node and selects it. /// Bound to the "Go To" button in the variables panel. @@ -1249,9 +1658,30 @@ private void OnModelSystemVariablesChanged(object? sender, // Full rebuild keeps the code simple; the list is expected to be small. SyncModelSystemVariables(); OnPropertyChanged(nameof(HasNoModelSystemVariables)); + OnPropertyChanged(nameof(HasNoVariablesAtAll)); OnPropertyChanged(nameof(FilteredModelSystemVariables)); } + /// Rebuilds from the given FunctionTemplate's local variable list. + /// Pass null to clear (when leaving a FunctionTemplate). + private void SyncLocalVariables(FunctionTemplate? ft) + { + foreach (var old in LocalVariables) old.Detach(); + LocalVariables.Clear(); + if (ft is not null) + foreach (var node in ft.LocalVariables) + LocalVariables.Add(new ModelSystemVariableViewModel(node)); + OnPropertyChanged(nameof(HasLocalVariables)); + OnPropertyChanged(nameof(HasNoVariablesAtAll)); + OnPropertyChanged(nameof(FilteredLocalVariables)); + } + + private void OnLocalVariablesChanged(object? sender, + System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + SyncLocalVariables(_currentFunctionTemplate?.UnderlyingTemplate); + } + /// Commit the name/comment currently in back to the model. [RelayCommand] private async Task CommitRename() @@ -1304,6 +1734,422 @@ 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) + { + // Subscribe to the incoming template's FunctionParameters so the canvas stays in sync. + ((INotifyCollectionChanged)ftvm.UnderlyingTemplate.FunctionParameters).CollectionChanged + += OnFunctionParametersChanged; + // Subscribe to local variables so the dialog list stays in sync. + ((INotifyCollectionChanged)ftvm.UnderlyingTemplate.LocalVariables).CollectionChanged + += OnLocalVariablesChanged; + _currentFunctionTemplate = ftvm; + SyncLocalVariables(ftvm.UnderlyingTemplate); + OnPropertyChanged(nameof(IsInsideFunctionTemplate)); + ExitFunctionTemplateCommand.NotifyCanExecuteChanged(); + NavigateUpCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigateUp)); + 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(); + 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 + /// + /// Adds a new to the current function template. + /// + /// Only available when the canvas is inside a function template (i.e. + /// is true). + /// + /// + public async Task AddFunctionParameterAsync(string name, Type type, Rectangle location) + { + if (_currentFunctionTemplate is null) return; + + if (!Session.AddFunctionParameter( + User, _currentFunctionTemplate.UnderlyingTemplate, + name, type, location, + out _, out var error)) + await ShowError("Add Function Parameter Failed", error); + } + + /// + /// Creates a whose type matches 's + /// element type, places it at (, ) on the canvas, + /// and immediately creates a link from via + /// to the new parameter — all within the current function template. + /// + public async Task AddFunctionParameterFromHookAsync( + NodeViewModel nodeVm, NodeHook hook, double x, double y) + { + if (_currentFunctionTemplate is null) return; + + // For array hooks use the element type; otherwise use the hook type directly. + var paramType = (hook.Cardinality is HookCardinality.AtLeastOne or HookCardinality.AnyNumber) + ? (hook.Type.GetElementType() ?? hook.Type) + : hook.Type; + + // Generate a unique name based on the hook name. + var baseName = hook.Name; + var name = baseName; + int idx = 2; + while (_currentFunctionTemplate.UnderlyingTemplate.FunctionParameters + .Any(fp => string.Equals(fp.Name, name, StringComparison.OrdinalIgnoreCase))) + name = $"{baseName}{idx++}"; + + var location = new Rectangle((float)x, (float)y, 180f, 40f); + + if (!Session.AddFunctionParameter( + User, _currentFunctionTemplate.UnderlyingTemplate, + name, paramType, location, + out var parameter, out var error)) + { + await ShowError("Add Function Parameter Failed", error); + return; + } + + // Wire the hook → FunctionParameter link. + if (!Session.AddLink(User, nodeVm.UnderlyingNode, hook, parameter!, out _, out var linkError)) + await ShowError("Create Link Failed", linkError); + } + + /// + /// Removes a from the current function template. + /// + /// + /// Prompts the user for a new name and renames the given . + /// + public async Task RenameFunctionParameterAsync(FunctionParameterViewModel fpvm) + { + if (ParentWindow is null) return; + + var dialog = new InputDialog( + title: "Rename Function Parameter", + prompt: "Enter the new name:", + defaultText: fpvm.Name); + await dialog.ShowDialog(ParentWindow); + + var newName = dialog.InputText?.Trim(); + if (string.IsNullOrEmpty(newName) || dialog.WasCancelled) return; + if (newName == fpvm.Name) return; + + if (!Session.RenameFunctionParameter(User, fpvm.UnderlyingParameter.Template, + fpvm.UnderlyingParameter, newName, out var error)) + await ShowError("Rename Function Parameter Failed", error); + } + + public async Task RemoveFunctionParameterAsync(FunctionParameter parameter) + { + if (_currentFunctionTemplate is null) return; + + if (!Session.RemoveFunctionParameter( + User, _currentFunctionTemplate.UnderlyingTemplate, parameter, out var error)) + await ShowError("Remove Function Parameter Failed", error); + } + + /// + /// Toggles whether is a local variable of the current + /// . If it is already in + /// it is removed; otherwise it is added. Validation is performed by the session. + /// + public async Task ToggleFunctionTemplateVariableAsync(Node node) + { + if (_currentFunctionTemplate is null) return; + var template = _currentFunctionTemplate.UnderlyingTemplate; + if (template.LocalVariables.Contains(node)) + { + if (!Session.RemoveFunctionTemplateVariable(User, template, node, out var error)) + await ShowError("Remove Local Variable Failed", error); + } + else + { + if (!Session.AddFunctionTemplateVariable(User, template, node, out var error)) + await ShowError("Add Local Variable 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 /// . @@ -1618,6 +2464,83 @@ 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. + /// + [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); + } + + /// + /// 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 . @@ -1632,12 +2555,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. @@ -1646,6 +2577,7 @@ private async Task Redo() { if (!Session.Redo(User, out var error)) await ShowError("Redo Failed", error!); + RenderRequested?.Invoke(this, EventArgs.Empty); } /// @@ -1668,6 +2600,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 +2633,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. @@ -1733,6 +2683,119 @@ 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) ─ + + /// Add a new Start at the specified canvas position using an auto-generated name. + public void AddStartAt(double x, double y) + { + var location = new Rectangle((float)x, (float)y); + Session.AddModelSystemStart(User, _currentBoundary, $"Start {++_startCounter}", location, out _, out _); + } + + /// Show a type-picker then add a new module node at the specified canvas position, named after the chosen type. + 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 location = new Rectangle((float)x, (float)y); + Session.AddNodeGenerateParameters(User, _currentBoundary, selectedType.Name, selectedType, location, out var addedNode, out _, out _); + + // AddNodeGenerateParameters also adds parameter child nodes, each of which triggers + // OnModulesChanged → SelectElement. Re-select the root module node so it ends up selected. + if (addedNode is not null) + { + var nvm = Nodes.FirstOrDefault(v => v.UnderlyingNode == addedNode); + if (nvm is not null) SelectElement(nvm); + } + } + + /// 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 _); + } + + /// Create a new function template at the specified canvas position with a unique auto-generated name. + public async Task AddFunctionTemplateAtAsync(double x, double y) + { + // Generate a name that doesn't clash with any existing template in the boundary. + int idx = FunctionTemplates.Count + 1; + string name; + do { name = $"FunctionTemplate{idx++}"; } + while (FunctionTemplates.Any(ft => string.Equals(ft.Name, name, StringComparison.OrdinalIgnoreCase))); + + 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 _); + } + + /// Pick a template (when more than one exists) then place a new function instance at the specified canvas position with an auto-generated name. + public async Task AddFunctionInstanceAtAsync(double x, double y) + { + 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; + } + + FunctionTemplate selectedTemplate; + if (availableTemplates.Count == 1) + { + selectedTemplate = availableTemplates[0]; + } + else + { + if (ParentWindow is null) return; + var templateDisplayNames = availableTemplates + .Select(ft => Boundary.GetQualifiedTemplateName(_currentBoundary, ft) ?? ft.Name) + .ToList(); + 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]; + } + + // Generate a unique instance name. + int idx = FunctionInstances.Count + 1; + string name; + do { name = $"{selectedTemplate.Name}{idx++}"; } + while (FunctionInstances.Any(fi => string.Equals(fi.Name, name, StringComparison.OrdinalIgnoreCase))); + + 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() @@ -1747,6 +2810,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/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/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/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; diff --git a/src/XTMF2.GUI/ViewModels/StartViewModel.cs b/src/XTMF2.GUI/ViewModels/StartViewModel.cs index dda7e50..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; @@ -116,6 +122,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). @@ -131,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); + } } 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/ModelSystemEditorView.axaml.cs b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs index 6f4e944..2315e61 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs @@ -19,8 +19,12 @@ You should have received a copy of the GNU General Public License using System; using System.ComponentModel; using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Styling; using XTMF2.GUI.ViewModels; namespace XTMF2.GUI.Views; @@ -29,57 +33,53 @@ public partial class ModelSystemEditorView : UserControl { private ModelSystemEditorViewModel? _vm; - // ── Destination list drag-and-drop state ──────────────────────────── - private int _destDragIndex = -1; // index captured on pointer-press - private int _destActiveDragFrom = -1; // index that is currently being dragged - private bool _destDragging = false; - private double _destDragStartY = 0; // Y position at press, used for threshold - private const double DestDragThreshold = 5.0; - public ModelSystemEditorView() { InitializeComponent(); DataContextChanged += OnDataContextChanged; AttachedToVisualTree += OnAttachedToVisualTree; - // Pressing Enter in the parameter value box commits the value. - ParameterValueEditBox.KeyDown += OnParameterValueEditBoxKeyDown; - - // Escape in the variable filter box clears the filter. - VariableFilterBox.KeyDown += OnVariableFilterBoxKeyDown; - - // F2 anywhere in this view focuses the rename box (when an element is selected). + // F2 anywhere in this view fires inline rename on the canvas. KeyDown += OnViewKeyDown; - // Enter in the node search box picks the first match and returns focus to the canvas. + // Enter / dropdown-closed in the node search box navigates the canvas. NodeSearchBox.KeyDown += OnNodeSearchBoxKeyDown; NodeSearchBox.DropDownClosed += OnNodeSearchBoxDropDownClosed; - // Destination list drag-and-drop for re-ordering MultiLink destinations. - DestinationListBox.PointerPressed += OnDestListPointerPressed; - DestinationListBox.PointerMoved += OnDestListPointerMoved; - DestinationListBox.PointerReleased += OnDestListPointerReleased; - - // Double-tap a destination entry to navigate the canvas to that node. - DestinationListBox.DoubleTapped += OnDestListDoubleTapped; - // Boundary navigation dropdown. BoundaryNavComboBox.SelectionChanged += OnBoundaryNavSelectionChanged; + + // Track theme changes so the BoxShadow style class stays in sync. + ActualThemeVariantChanged += OnActualThemeVariantChanged; + } + + private void OnActualThemeVariantChanged(object? sender, EventArgs e) + { + UpdateThemeClass(); } + private void UpdateThemeClass() + { + var isLight = ActualThemeVariant == ThemeVariant.Light; + if (isLight) + Classes.Add("light-mode"); + else + Classes.Remove("light-mode"); + DockBar.BoxShadow = BoxShadows.Parse(isLight + ? "0 2 10 2 #500066CC, 0 4 20 0 #28000088" + : "0 0 14 3 #8000D4FF, 0 0 40 10 #4000A0FF, 0 6 24 0 #80000000"); + } + + // -- Keyboard --------------------------------------------------------------- + private void OnViewKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.F2 && _vm?.SelectedElement is not null) { if (_vm.SelectedElement is CommentBlockViewModel) - { TheCanvas.BeginCommentEditForSelected(); - } else - { - // Route F2 for nodes/starts to the inline canvas name editor. TheCanvas.BeginNameEditForSelected(); - } e.Handled = true; } else if (e.Key == Key.S && e.KeyModifiers.HasFlag(KeyModifiers.Control)) @@ -100,31 +100,17 @@ private void OnViewKeyDown(object? sender, KeyEventArgs e) } } - private void OnVariableFilterBoxKeyDown(object? sender, KeyEventArgs e) - { - if (e.Key == Key.Escape && _vm is not null) - { - _vm.VariableFilter = string.Empty; - e.Handled = true; - } - } - - 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; @@ -133,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; @@ -147,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) @@ -168,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; @@ -184,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) { @@ -201,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) @@ -210,123 +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; - var mid = container.Bounds.Top + 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) - indicatorY = c.Bounds.Top; + _variablesDialog.Closed += (_, _) => _variablesDialog = null; } - else if (DestinationListBox.ItemCount > 0) + else { - if (DestinationListBox.ContainerFromIndex(DestinationListBox.ItemCount - 1) is Control last) - indicatorY = last.Bounds.Bottom; + _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..7a3bded --- /dev/null +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs new file mode 100644 index 0000000..ab6ebd2 --- /dev/null +++ b/src/XTMF2.GUI/Views/ModelSystemVariablesDialog.axaml.cs @@ -0,0 +1,71 @@ +/* + 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; + + Opened += (_, _) => FilterBox.Focus(); + } + + 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(); +} diff --git a/src/XTMF2.GUI/Views/ModelSystemsView.axaml b/src/XTMF2.GUI/Views/ModelSystemsView.axaml index c714818..2d69c9f 100644 --- a/src/XTMF2.GUI/Views/ModelSystemsView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemsView.axaml @@ -10,7 +10,7 @@ x:Class="XTMF2.GUI.Views.ModelSystemsView" x:CompileBindings="True" x:DataType="vm:ModelSystemsViewModel" - > + Classes="neon-view"> @@ -27,7 +27,7 @@ @@ -36,25 +36,22 @@ - 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 @@ + x:CompileBindings="True" + Classes="neon-view"> @@ -26,7 +27,7 @@ @@ -35,6 +36,7 @@ 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 628e72c..b71feeb 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); @@ -1077,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() { @@ -1107,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); @@ -1122,9 +1206,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(); @@ -1144,6 +1229,7 @@ void RestoreGhostCascade() RemoveGhostCascade(); foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); + RemoveHiddenCascade(); ModelSystem.Variables.Remove(node); return (boundary.RemoveNode(node, out var e), e); })); @@ -1151,7 +1237,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(); @@ -1336,6 +1423,59 @@ 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; + } + + /// + /// Returns true if any of + /// anywhere in the model system has an active link originating at the + /// that corresponds to . + /// Traverses every reachable boundary (including ). + /// + private static bool HasActiveFunctionParameterLink( + Boundary root, FunctionTemplate template, FunctionParameter parameter) + { + var stack = new Stack(); + stack.Push(root); + 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 link in current.Links) + { + if (link.Origin is FunctionInstance fi + && ReferenceEquals(fi.Template, template) + && link.OriginHook is FunctionParameterHook fph + && ReferenceEquals(fph.Parameter, parameter)) + return true; + } + } + return false; + } + private List GetLinksGoingTo(Node destNode) { var ret = new List(); @@ -1430,6 +1570,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(); @@ -1440,6 +1603,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); @@ -1506,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; @@ -1570,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); @@ -1601,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. /// @@ -1672,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 /// @@ -1868,6 +2125,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; @@ -1907,6 +2169,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}"); @@ -1923,13 +2198,524 @@ 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; + } + + // 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(() => + { + template.SetEntryNode(oldEntryNode); + return (true, null); + }, () => + { + template.SetEntryNode(entryNode); + return (true, null); + })); + return true; + } + } + + /// + /// Adds a new to . + /// The parameter becomes a valid link destination inside the template and a hook + /// on every that references the template. + /// + public bool AddFunctionParameter(User user, FunctionTemplate template, string name, Type type, + Rectangle location, out FunctionParameter? parameter, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(type); + parameter = null; + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + if (!template.AddFunctionParameter(name, type, location, out parameter, out error)) + return false; + var captured = parameter!; + var capturedIdx = template.FunctionParameters.Count - 1; + Buffer.AddUndo(new Command(() => + { + template.RemoveFunctionParameter(captured, out var e); + return (true, e); + }, () => + { + template.RestoreFunctionParameter(captured, capturedIdx); + return (true, null); + })); + return true; + } + } + + /// + /// Removes a from . + /// Any links inside the template that point to the parameter are also removed. + /// + public bool RemoveFunctionParameter(User user, FunctionTemplate template, FunctionParameter parameter, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(parameter); + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + // Refuse deletion if any FunctionInstance in the model has an active link + // from the hook that corresponds to this FunctionParameter. + if (HasActiveFunctionParameterLink(ModelSystem.GlobalBoundary, template, parameter)) + { + error = new CommandError( + $"Cannot remove FunctionParameter '{parameter.Name}': one or more FunctionInstances have a link connected to this parameter's hook."); + return false; + } + // Capture state before any mutations. + var internalBoundary = template.InternalModules; + var linksToRemove = internalBoundary.Links + .Where(l => l.HasDestination(parameter)) + .ToList(); + int restoreIndex = template.FunctionParameters.IndexOf(parameter); + + // Remove links that reference this parameter as a destination. + foreach (var link in linksToRemove) + internalBoundary.RemoveLink(link, out _); + + if (!template.RemoveFunctionParameter(parameter, out error)) + { + // Roll back link removals on failure. + foreach (var link in linksToRemove) + internalBoundary.AddLink(link, out _); + return false; + } + Buffer.AddUndo(new Command(() => + { + // Undo: restore parameter and its links. + template.RestoreFunctionParameter(parameter, restoreIndex); + foreach (var link in linksToRemove) + internalBoundary.AddLink(link, out _); + return (true, null); + }, () => + { + // Redo: re-remove links then the parameter. + foreach (var link in linksToRemove) + internalBoundary.RemoveLink(link, out _); + return (template.RemoveFunctionParameter(parameter, out var e), e); + })); + return true; + } + } + + /// + /// Renames a within >. + /// Also updates the corresponding name on every + /// live because the hook derives its name from the parameter. + /// + public bool RenameFunctionParameter(User user, FunctionTemplate template, FunctionParameter parameter, + string newName, [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(parameter); + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + var oldName = parameter.Name; + if (!template.RenameFunctionParameter(parameter, newName, out error)) + return false; + Buffer.AddUndo(new Command(() => + { + template.RenameFunctionParameter(parameter, oldName, out _); + return (true, null); + }, () => + { + template.RenameFunctionParameter(parameter, newName, out _); + 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; + } + } + + /// + /// 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 /// /// 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..d6cae51 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,27 @@ 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); + + /// + /// 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) { @@ -171,6 +174,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 +206,17 @@ static List GetUsedTypes(Boundary current, List included) { GetUsedTypes(child, included); } + foreach (var ft in current._functionTemplates) + { + // Include types used by FunctionParameters (they may not appear in any regular node). + foreach (var fp in ft.FunctionParameters) + { + var fpt = fp.Type; + if (fpt != null && !included.Contains(fpt)) + included.Add(fpt); + } + GetUsedTypes(ft.InternalModules, included); + } return included; } return GetUsedTypes(this, new List()).Select((type, index) => (type, index)) @@ -235,6 +255,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 +370,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 +493,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 +657,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 +684,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 +776,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 +804,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 +881,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 +1059,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..f264292 --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/FunctionInstance.cs @@ -0,0 +1,512 @@ +/* + 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.Collections.Specialized; +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 +{ + /// + /// 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; + // Rebuild the cached Hooks list whenever FunctionParameters change. + ((INotifyCollectionChanged)template.FunctionParameters).CollectionChanged += OnFunctionParametersChanged; + // Track individual FunctionParameter renames so hook labels stay current. + foreach (var fp in template.FunctionParameters) + ((INotifyPropertyChanged)fp).PropertyChanged += OnFunctionParameterPropertyChanged; + } + + private void OnFunctionParametersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // Manage per-item subscriptions for newly added / removed parameters. + if (e.OldItems is not null) + foreach (FunctionParameter fp in e.OldItems) + ((INotifyPropertyChanged)fp).PropertyChanged -= OnFunctionParameterPropertyChanged; + if (e.NewItems is not null) + foreach (FunctionParameter fp in e.NewItems) + ((INotifyPropertyChanged)fp).PropertyChanged += OnFunctionParameterPropertyChanged; + + _cachedHooks = null; + InvokePropertyChanged(nameof(Hooks)); + } + + private void OnFunctionParameterPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(FunctionParameter.Name)) + { + // FunctionParameterHook.Name is a live computed property, so we only need + // to notify observers that the Hooks collection's labels have changed. + _cachedHooks = null; + InvokePropertyChanged(nameof(Hooks)); + } + } + + 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); + + // ── Dynamic hooks (one per FunctionParameter) ───────────────────── + + private IReadOnlyList? _cachedHooks; + + /// + /// The outgoing hooks of this function instance, one per + /// in the referenced template. + /// Each hook corresponds to a placeholder inside the + /// template; linking FI.hookForP → ExternalNode supplies the module that will + /// fill parameter P at run-time. + /// + public override IReadOnlyList Hooks + { + get + { + if (_cachedHooks is null) + { + var list = new List(); + int idx = 0; + foreach (var fp in Template.FunctionParameters) + list.Add(new FunctionParameterHook(fp, idx++)); + _cachedHooks = list.AsReadOnly(); + } + return _cachedHooks; + } + } + + // ── 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; + + /// + /// Maps each of the template to the external + /// that was wired to this instance via a + /// link. + /// Populated during on the enclosing boundary; + /// null between runs. + /// + 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. + /// Called by when processing an outgoing + /// link. + /// + internal void BindParameter(FunctionParameter parameter, IModule? module) + { + _parameterBindings ??= new(ReferenceEqualityComparer.Instance); + _parameterBindings[parameter] = module; + } + + /// + /// 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); + _parameterBindings = 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!; + 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. + /// + 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) + { + // ── Transitive FunctionParameter wiring ─────────────────────────────── + if (sl.Destination is FunctionParameter fp) + { + if (_parameterBindings is not null + && _parameterBindings.TryGetValue(fp, out var boundModule) + && boundModule is not null) + { + sl.OriginHook.Install(originModule, boundModule, 0); + } + else if (sl.OriginHook.Cardinality == HookCardinality.Single) + { + error = $"FunctionInstance '{Name}': FunctionParameter '{fp.Name}' has no external binding " + + $"but hook '{sl.OriginHook.Name}' requires one (Single cardinality)."; + return false; + } + return true; + } + + 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) + { + if (d is FunctionParameter fpDest) + { + if (_parameterBindings is not null + && _parameterBindings.TryGetValue(fpDest, out var bm) && bm is not null) + enabled++; + } + else + { + var r = d is GhostNode rGn ? rGn.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) + { + if (d is FunctionParameter fpDest) + { + if (_parameterBindings is not null + && _parameterBindings.TryGetValue(fpDest, out var bm) && bm is not null) + ml.OriginHook.Install(originModule, bm, idx++); + } + else + { + var r = d is GhostNode rGn ? rGn.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/FunctionParameter.cs b/src/XTMF2/ModelSystemConstruct/FunctionParameter.cs new file mode 100644 index 0000000..bf15771 --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/FunctionParameter.cs @@ -0,0 +1,175 @@ +/* + 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.Text.Json; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using XTMF2.Editing; + +namespace XTMF2.ModelSystemConstruct +{ + /// + /// Represents a typed parameter slot on a . + /// + /// A is a virtual "placeholder" node that lives inside + /// a function template's . Internal nodes + /// within that template can draw links to a , + /// signalling that at run-time the concrete module will be supplied from outside via the + /// corresponding hook. + /// + /// + /// objects are not stored in + /// of InternalModules; they are owned exclusively + /// by and receive node-dictionary + /// indices during save so that cross-boundary links can reference them. + /// + /// + public sealed class FunctionParameter : Node + { + // ── JSON property names ─────────────────────────────────────────── + internal const string FpNameProperty = "Name"; + internal const string FpTypeProperty = "Type"; + internal const string FpIndexProperty = "Index"; + internal const string FpXProperty = "X"; + internal const string FpYProperty = "Y"; + internal const string FpWProperty = "Width"; + internal const string FpHProperty = "Height"; + + /// + /// The that owns this parameter. + /// + public FunctionTemplate Template { get; } + + /// + /// Constructs a new owned by . + /// + /// The parameter name; must be unique within the template. + /// The required module type for this parameter slot. + /// The owning . + /// Canvas position inside InternalModules. + public FunctionParameter(string name, Type type, FunctionTemplate template, Rectangle location) + : base(name, type, template.InternalModules, Array.Empty(), location) + { + Template = template; + } + + // ── Persistence ─────────────────────────────────────────────────── + + /// + /// Writes this parameter to and records its index in + /// so that links can reference it later. + /// + internal new void Save(ref int index, Dictionary nodeDictionary, + Dictionary typeDictionary, Utf8JsonWriter writer) + { + if (!nodeDictionary.TryGetValue(this, out int myIndex)) + { + myIndex = index++; + nodeDictionary[this] = myIndex; + } + writer.WriteStartObject(); + writer.WriteString(FpNameProperty, Name); + writer.WriteNumber(FpTypeProperty, typeDictionary[Type!]); + writer.WriteNumber(FpIndexProperty, myIndex); + writer.WriteNumber(FpXProperty, Location.X); + writer.WriteNumber(FpYProperty, Location.Y); + writer.WriteNumber(FpWProperty, Location.Width); + writer.WriteNumber(FpHProperty, Location.Height); + writer.WriteEndObject(); + } + + /// + /// Reads a from and + /// registers it in . + /// + internal static bool Load( + Dictionary typeLookup, + Dictionary nodeDictionary, + ref Utf8JsonReader reader, + FunctionTemplate owningTemplate, + [NotNullWhen(true)] out FunctionParameter? parameter, + [NotNullWhen(false)] ref string? error) + { + parameter = null; + + if (reader.TokenType != JsonTokenType.StartObject) + { + error = "Expected StartObject when loading FunctionParameter."; + return false; + } + + string? name = null; + Type? type = null; + int fpIndex = -1; + float x = 80f, y = 80f, w = 140f, h = 40f; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) continue; + + if (reader.ValueTextEquals(FpNameProperty)) + { + reader.Read(); + name = reader.GetString(); + } + else if (reader.ValueTextEquals(FpTypeProperty)) + { + reader.Read(); + var ti = reader.GetInt32(); + if (!typeLookup.TryGetValue(ti, out type)) + { + error = $"FunctionParameter: unknown type index {ti}."; + return false; + } + } + else if (reader.ValueTextEquals(FpIndexProperty)) + { + reader.Read(); + fpIndex = reader.GetInt32(); + } + else if (reader.ValueTextEquals(FpXProperty)) { reader.Read(); x = reader.GetSingle(); } + else if (reader.ValueTextEquals(FpYProperty)) { reader.Read(); y = reader.GetSingle(); } + else if (reader.ValueTextEquals(FpWProperty)) { reader.Read(); w = reader.GetSingle(); } + else if (reader.ValueTextEquals(FpHProperty)) { reader.Read(); h = reader.GetSingle(); } + else reader.Skip(); + } + + if (name is null) + { + error = "FunctionParameter is missing its Name."; + return false; + } + if (type is null) + { + error = $"FunctionParameter '{name}' is missing its Type."; + return false; + } + if (fpIndex < 0) + { + error = $"FunctionParameter '{name}' is missing a valid Index."; + return false; + } + + parameter = new FunctionParameter(name, type, owningTemplate, new Rectangle(x, y, w, h)); + nodeDictionary[fpIndex] = parameter; + error = null; + return true; + } + } +} diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs index 70b3374..8ad4fff 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs @@ -18,22 +18,42 @@ 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.Text; +using System.Linq; using System.Text.Json; using XTMF2.Editing; using XTMF2.Repository; +using XTMF2.RuntimeModules; namespace XTMF2.ModelSystemConstruct { /// /// This class provides the logic for creating a function template. /// Function templates are then used in a model system by instantiating all of the - /// needed references. + /// needed references via . + /// + /// A function template may declare zero or more . + /// Each acts as a typed placeholder inside the + /// template's boundary: internal nodes link to + /// parameters, and the concrete module is provided at run-time by the + /// that instantiates this template. + /// /// public sealed class FunctionTemplate : INotifyPropertyChanged { + // ── JSON property names ─────────────────────────────────────────── + private const string NameProperty = "Name"; + 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"; + private const string LocationHProperty = "Height"; + private string _name = String.Empty; /// @@ -49,11 +69,165 @@ 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))); + } + + // ── 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(); + + /// + /// The typed parameter slots declared on this function template. + /// + /// Each is a virtual placeholder inside + /// : internal nodes link to a parameter, + /// and the actual module is supplied at run-time by the + /// that instantiates this template. + /// On the parent boundary's canvas a exposes one + /// outgoing hook per , allowing external nodes to + /// be wired in. + /// + /// + public ReadOnlyObservableCollection FunctionParameters { get; } + + /// + /// Adds a new to this template. + /// The name must be unique within this template. + /// + internal bool AddFunctionParameter(string name, Type type, Rectangle location, + [NotNullWhen(true)] out FunctionParameter? parameter, + [NotNullWhen(false)] out CommandError? error) + { + if (string.IsNullOrWhiteSpace(name)) + { + parameter = null; + error = new CommandError("A FunctionParameter name must not be empty."); + return false; + } + if (_functionParameters.Any(fp => fp.Name.Equals(name, StringComparison.Ordinal))) + { + parameter = null; + error = new CommandError($"A FunctionParameter named '{name}' already exists in template '{Name}'."); + return false; + } + parameter = new FunctionParameter(name, type, this, location); + _functionParameters.Add(parameter); + error = null; + return true; + } + + /// + /// Forcibly adds an already-constructed back to the + /// collection (used for undo of a removal). + /// + internal void RestoreFunctionParameter(FunctionParameter parameter, int index) + { + if (!_functionParameters.Contains(parameter)) + _functionParameters.Insert(Math.Min(index, _functionParameters.Count), parameter); + } + + /// + /// Removes a from this template. + /// + internal bool RemoveFunctionParameter(FunctionParameter parameter, + [NotNullWhen(false)] out CommandError? error) + { + if (!_functionParameters.Remove(parameter)) + { + error = new CommandError($"FunctionParameter '{parameter.Name}' was not found in template '{Name}'."); + return false; + } + error = null; + return true; + } + + /// + /// Renames to . + /// The new name must be unique within this template. + /// + internal bool RenameFunctionParameter(FunctionParameter parameter, string newName, + [NotNullWhen(false)] out CommandError? error) + { + if (string.IsNullOrWhiteSpace(newName)) + { + error = new CommandError("A FunctionParameter name must not be empty."); + return false; + } + if (_functionParameters.Any(fp => !ReferenceEquals(fp, parameter) + && fp.Name.Equals(newName, StringComparison.Ordinal))) + { + error = new CommandError($"A FunctionParameter named '{newName}' already exists in template '{Name}'."); + return false; + } + parameter.SetName(newName, out _); + error = null; + return true; + } + /// /// 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 +239,178 @@ 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); + // 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) + { + 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( + $"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; + } + + /// + /// 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. + /// + 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; } /// /// 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(); + + // FunctionParameters BEFORE InternalModules so their indices are defined + // before any internal links that reference them as destinations are written. + writer.WritePropertyName(FunctionParametersProperty); + writer.WriteStartArray(); + foreach (var fp in _functionParameters) + { + // Ensure the type is recorded. + if (!typeDictionary.ContainsKey(fp.Type!)) + typeDictionary[fp.Type!] = typeDictionary.Count; + fp.Save(ref index, nodeDictionary, typeDictionary, writer); + } + writer.WriteEndArray(); + + // Internal modules (must come after FunctionParameters so FP indices are established). writer.WritePropertyName(nameof(InternalModules)); InternalModules.Save(ref index, nodeDictionary, typeDictionary, writer); + + // Entry node – stored as an integer index (null means not set) + 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(); } @@ -106,42 +432,123 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo return Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, parent, out template, ref error); } - internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters, + internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, + List<(Node toAssignTo, string parameterExpression)> scriptedParameters, List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes, - ref Utf8JsonReader reader, Boundary parent, [NotNullWhen(true)] out FunctionTemplate? template, [NotNullWhen(false)] ref string? error) + ref Utf8JsonReader reader, Boundary parent, + [NotNullWhen(true)] out FunctionTemplate? template, + [NotNullWhen(false)] ref string? error) { template = null; string? name = null; + Rectangle location = new Rectangle(40, 40, 200, 120); var innerModules = new Boundary(parent); - if(reader.TokenType != JsonTokenType.StartObject) - { + int? deferredEntryNodeIndex = null; + + if (reader.TokenType != JsonTokenType.StartObject) return Helper.FailWith(out error, "Unexpected token when reading FunctionTemplate!"); - } - while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) + + // 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) { - if(reader.TokenType != JsonTokenType.PropertyName) + if (reader.TokenType != JsonTokenType.PropertyName) continue; + + if (reader.ValueTextEquals(NameProperty)) + { + reader.Read(); + name = reader.GetString(); + if (partialTemplate is null && name is not null) + partialTemplate = new FunctionTemplate(name, parent, innerModules); + } + else if (reader.ValueTextEquals(LocationProperty)) { - continue; + 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); } - if(reader.ValueTextEquals(nameof(Name))) + else if (reader.ValueTextEquals(FunctionParametersProperty)) + { + // FunctionParameters must be loaded before InternalModules so that + // their node-dict indices are in the dictionary when internal links are loaded. + if (partialTemplate is null) + { + error = "FunctionParameters section appeared before the template Name was read."; + return false; + } + reader.Read(); // StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (!FunctionParameter.Load(typeLookup, node, ref reader, partialTemplate, + out var fp, ref error)) + return false; + partialTemplate._functionParameters.Add(fp!); + } + } + else if (reader.ValueTextEquals(nameof(InternalModules))) { reader.Read(); - name = reader.GetString(); + if (!innerModules.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error)) + return false; } - else if(reader.ValueTextEquals(nameof(InternalModules))) + else if (reader.ValueTextEquals(EntryNodeProperty)) { reader.Read(); - if(!innerModules.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error)) + 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) { - return false; + if (reader.TokenType == JsonTokenType.Number) + deferredLocalVarIds.Add(reader.GetInt32()); } } + else + { + reader.Skip(); + } } - if(name is null) - { + + if (name is null) return Helper.FailWith(out error, "Function template did not include a name!"); + + template = partialTemplate ?? new FunctionTemplate(name, parent, innerModules); + template.SetLocation(location); + + // Resolve the entry node index. + if (deferredEntryNodeIndex.HasValue + && node.TryGetValue(deferredEntryNodeIndex.Value, out var entryNodeCandidate)) + { + template._entryNode = entryNodeCandidate; } - template = new FunctionTemplate(name, parent); + + // 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/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/Link.cs b/src/XTMF2/ModelSystemConstruct/Link.cs index 7e6b497..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()); @@ -159,19 +173,21 @@ internal static bool Create(ModuleRepository modules, Dictionary node { return FailWith(out link, out error, "No destination specified on link!"); } - var hook = modules[origin!.Type!].Hooks?.FirstOrDefault(h => h.Name.Equals(hookName, StringComparison.OrdinalIgnoreCase)); + var hook = origin is FunctionInstance fi + ? fi.Hooks.FirstOrDefault(h => h.Name.Equals(hookName, StringComparison.OrdinalIgnoreCase)) + : modules[origin!.Type!].Hooks?.FirstOrDefault(h => h.Name.Equals(hookName, StringComparison.OrdinalIgnoreCase)); if(hook == null) { return FailWith(out link, out error, "Unable to find a hook with the name " + hookName); } 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; } @@ -186,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/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/MultiLink.cs b/src/XTMF2/ModelSystemConstruct/MultiLink.cs index 6a5a9ce..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,16 +76,35 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit { writer.WriteBoolean(DisabledProperty, true); } + if (IsOrthogonal) + { + writer.WriteBoolean(OrthogonalProperty, true); + } writer.WriteEndObject(); } 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); + } + + // FunctionParameter destinations are handled transitively by FunctionInstance at runtime; + // exclude them from the count and installation entirely. var moduleCount = _Destinations.Count(d => { - var effective = d is GhostNode gn ? gn.ReferencedNode : d; - return !effective.IsDisabled; + if (d is FunctionParameter) return false; + var (node, _) = ResolveDest(d); + return !node.IsDisabled; }); if(OriginHook!.Cardinality == HookCardinality.AtLeastOne) { @@ -106,10 +125,12 @@ 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) + // Skip FunctionParameter destinations — resolved transitively via FunctionInstance. + if (_Destinations[i] is FunctionParameter) continue; + 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..58e3bbe 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 @@ -87,7 +87,7 @@ private void CreateNodeHooks(ModuleRepository repository) /// /// Get a readonly list of possible hooks to use to interface with other nodes. /// - public IReadOnlyList Hooks { get; private set; } + public virtual IReadOnlyList Hooks { get; private set; } /// /// The name of the node @@ -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..37c3464 100644 --- a/src/XTMF2/ModelSystemConstruct/NodeHook.cs +++ b/src/XTMF2/ModelSystemConstruct/NodeHook.cs @@ -29,7 +29,7 @@ namespace XTMF2 /// public abstract class NodeHook { - public string Name { get; private set; } + public virtual string Name { get; protected set; } public HookCardinality Cardinality { get; private set; } @@ -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,9 +254,69 @@ 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; } } + + /// + /// A hook on a that corresponds + /// to one of the owning template's + /// slots. Outgoing links from the function instance to external nodes use this hook type; + /// the actual module wiring is performed transitively by + /// . + /// + public sealed class FunctionParameterHook : NodeHook + { + /// The function-parameter slot this hook represents. + public ModelSystemConstruct.FunctionParameter Parameter { get; } + + /// + public override Type Type => Parameter.Type; + + /// The function-parameter slot this hook exposes. + /// Ordinal position among the template's FunctionParameters. + public FunctionParameterHook(ModelSystemConstruct.FunctionParameter parameter, int index) + : base(parameter.Name, HookCardinality.SingleOptional, index, isParameter: false, defaultValue: null) + { + Parameter = parameter; + } + + /// + /// Always returns the current name of the underlying , so that + /// renaming a is immediately visible on all + /// hook rows without rebuilding the hooks list. + /// + public override string Name => Parameter.Name; + + // ── Install is intentionally a no-op ───────────────────────────── + // The module wiring for FunctionParameter hooks is not performed through + // the standard Install path; instead SingleLink.Construct detects a + // FunctionInstance origin with a FunctionParameterHook and calls + // FunctionInstance.BindParameter directly. + + internal override void Install(Node origin, Node destination, int index) { /* no-op */ } + internal override void Install(IModule origin, IModule destination, int index) { /* no-op */ } + internal override void CreateArray(IModule origin, int length) { /* no-op */ } + internal override bool AnyInstalled(IModule module) => false; + } } diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variable.cs index d8b0b71..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; @@ -29,19 +30,52 @@ 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; + // 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) + { + var td = nt.GetGenericTypeDefinition(); + if (td == typeof(RuntimeModules.BasicParameter<>) + || td == typeof(RuntimeModules.ScriptedParameter<>)) + valueType = nt.GetGenericArguments()[0]; + } + 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 {valueType.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/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 new file mode 100644 index 0000000..1061e8c --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/Variables/FunctionParameterVariable.cs @@ -0,0 +1,75 @@ +/* + 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 XTMF2.ModelSystemConstruct; + +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. 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 + { + 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/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/src/XTMF2/ModelSystemConstruct/SingleLink.cs b/src/XTMF2/ModelSystemConstruct/SingleLink.cs index 8aefb59..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,13 +53,66 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit { writer.WriteBoolean(DisabledProperty, true); } + if (IsOrthogonal) + { + writer.WriteBoolean(OrthogonalProperty, true); + } writer.WriteEndObject(); } 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!; + // FunctionParameter destinations are resolved transitively at runtime by + // FunctionInstance.ConstructRuntimeLink(); no static wiring is needed here. + if (Destination is FunctionParameter) + { + error = null; + return true; + } + + // If the origin is a FunctionInstance wired through a FunctionParameterHook, + // record the parameter binding so FunctionInstance can route internal links + // to the actual external module at runtime. + if (Origin is FunctionInstance fiBinder && OriginHook is FunctionParameterHook fph) + { + var resolvedFiDest = Destination is GhostNode gnFi + ? gnFi.ReferencedNode + : Destination!; + IModule? bindModule; + if (resolvedFiDest is FunctionInstance destFiBinder) + { + bindModule = destFiBinder.Template.EntryNode is not null + ? destFiBinder.GetRuntimeModule(destFiBinder.Template.EntryNode) + : null; + } + else + { + bindModule = resolvedFiDest.Module; + } + fiBinder.BindParameter(fph.Parameter, bindModule); + error = null; + return true; + } + + // 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 +128,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/TestFunctionParameterRemovalGuard.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionParameterRemovalGuard.cs new file mode 100644 index 0000000..8b283d0 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionParameterRemovalGuard.cs @@ -0,0 +1,181 @@ +/* + 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 a cannot be removed from a +/// while any of that +/// template has an active link wired through the corresponding +/// . +/// +[TestClass] +public class TestFunctionParameterRemovalGuard +{ + // ── Scaffold ─────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "T") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static FunctionParameter AddFunctionParameter(User user, ModelSystemSession ms, + FunctionTemplate ft, string name = "Param") + { + Assert.IsTrue(ms.AddFunctionParameter(user, ft, name, typeof(SimpleTestModule), + new Rectangle(0, 0, 120, 50), out var fp, out var err), err?.Message); + return fp!; + } + + private static FunctionInstance AddFunctionInstance(User user, ModelSystemSession ms, + Boundary boundary, FunctionTemplate ft, string name = "FI") + { + Assert.IsTrue(ms.AddFunctionInstance(user, boundary, ft, name, + new Rectangle(200, 0, 180, 60), out var fi, out var err), err?.Message); + return fi!; + } + + private static Node AddNode(User user, ModelSystemSession ms, Boundary boundary, + string name = "Dest") + { + Assert.IsTrue(ms.AddNode(user, boundary, name, typeof(SimpleTestModule), + new Rectangle(400, 0, 120, 50), out var node, out var err), err?.Message); + return node!; + } + + // ── Tests ────────────────────────────────────────────────────────────── + + /// + /// Removing a that has no wired links succeeds. + /// + [TestMethod] + public void RemoveFunctionParameter_NoActiveLink_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionParameter_NoActiveLink_Succeeds), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var fp = AddFunctionParameter(user, ms, ft); + + Assert.IsTrue(ms.RemoveFunctionParameter(user, ft, fp, out var err), + err?.Message); + + Assert.IsEmpty(ft.FunctionParameters, + "Parameter should be removed when no link is wired."); + }); + } + + /// + /// Attempting to remove a while a + /// has an active link through its hook is rejected. + /// + [TestMethod] + public void RemoveFunctionParameter_WithActiveLink_Fails() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionParameter_WithActiveLink_Fails), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var fp = AddFunctionParameter(user, ms, ft); + var fi = AddFunctionInstance(user, ms, gb, ft); + var dest = AddNode(user, ms, gb); + + // Wire the FunctionParameterHook of fi to 'dest'. + var fpHook = fi.Hooks[0]; // FunctionParameterHook for fp + Assert.IsTrue(ms.AddLink(user, fi, fpHook, dest, out _, out var linkErr), + linkErr?.Message); + + // RemoveFunctionParameter must be blocked. + Assert.IsFalse(ms.RemoveFunctionParameter(user, ft, fp, out var err), + "Should be blocked while a FunctionInstance has an active link on this hook."); + Assert.IsNotNull(err, "An error description must be provided."); + Assert.HasCount(1, ft.FunctionParameters, + "Parameter must not be removed."); + }); + } + + /// + /// After the wired link is removed, the can be deleted. + /// + [TestMethod] + public void RemoveFunctionParameter_AfterLinkRemoval_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionParameter_AfterLinkRemoval_Succeeds), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var fp = AddFunctionParameter(user, ms, ft); + var fi = AddFunctionInstance(user, ms, gb, ft); + var dest = AddNode(user, ms, gb); + + var fpHook = fi.Hooks[0]; + Assert.IsTrue(ms.AddLink(user, fi, fpHook, dest, out var link, out var linkErr), + linkErr?.Message); + + // Remove the wiring, then the parameter should be removable. + Assert.IsTrue(ms.RemoveLink(user, link!, out var removeErr), removeErr?.Message); + Assert.IsTrue(ms.RemoveFunctionParameter(user, ft, fp, out var fpErr), + fpErr?.Message); + + Assert.IsEmpty(ft.FunctionParameters, + "Parameter should be removed after the link is deleted."); + }); + } + + /// + /// The guard works even when the lives in a + /// nested (the traversal must recurse into sub-boundaries). + /// + [TestMethod] + public void RemoveFunctionParameter_ActiveLinkInNestedBoundary_Fails() + { + TestHelper.RunInModelSystemContext(nameof(RemoveFunctionParameter_ActiveLinkInNestedBoundary_Fails), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var fp = AddFunctionParameter(user, ms, ft); + + // Place the FunctionInstance inside a nested boundary. + Assert.IsTrue(ms.AddBoundary(user, gb, "Inner", out var inner, out var bErr), + bErr?.Message); + var fi = AddFunctionInstance(user, ms, inner!, ft, "FI_Inner"); + var dest = AddNode(user, ms, inner!, "DestInner"); + + var fpHook = fi.Hooks[0]; + Assert.IsTrue(ms.AddLink(user, fi, fpHook, dest, out _, out var linkErr), + linkErr?.Message); + + // The guard must detect the link even though it is in a nested boundary. + Assert.IsFalse(ms.RemoveFunctionParameter(user, ft, fp, out var err), + "Should be blocked even for FunctionInstances in nested boundaries."); + Assert.IsNotNull(err); + }); + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionParameterRename.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionParameterRename.cs new file mode 100644 index 0000000..849149f --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionParameterRename.cs @@ -0,0 +1,199 @@ +/* + 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; + +/// +/// Tests for . +/// Verifies that a can be renamed, that the +/// resulting name is reflected on every 's +/// , and that undo/redo are supported. +/// +[TestClass] +public class TestFunctionParameterRename +{ + // ── Scaffold ─────────────────────────────────────────────────────────── + + private static FunctionTemplate AddTemplate(User user, ModelSystemSession ms, + Boundary boundary, string name = "T") + { + Assert.IsTrue(ms.AddFunctionTemplate(user, boundary, name, out var ft, out var err), + err?.Message); + return ft!; + } + + private static FunctionParameter AddFunctionParameter(User user, ModelSystemSession ms, + FunctionTemplate ft, string name = "Param") + { + Assert.IsTrue(ms.AddFunctionParameter(user, ft, name, typeof(SimpleTestModule), + new Rectangle(0, 0, 120, 50), out var fp, out var err), err?.Message); + return fp!; + } + + private static FunctionInstance AddFunctionInstance(User user, ModelSystemSession ms, + Boundary boundary, FunctionTemplate ft, string name = "FI") + { + Assert.IsTrue(ms.AddFunctionInstance(user, boundary, ft, name, + new Rectangle(200, 0, 180, 60), out var fi, out var err), err?.Message); + return fi!; + } + + // ── Basic rename ─────────────────────────────────────────────────────── + + /// Renaming to a valid unique name succeeds. + [TestMethod] + public void RenameFunctionParameter_ValidName_Succeeds() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_ValidName_Succeeds), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp = AddFunctionParameter(user, ms, ft, "Original"); + + Assert.IsTrue(ms.RenameFunctionParameter(user, ft, fp, "Renamed", out var err), + err?.Message); + + Assert.AreEqual("Renamed", fp.Name, + "FunctionParameter.Name must reflect the new name."); + }); + } + + /// An empty name is rejected. + [TestMethod] + public void RenameFunctionParameter_EmptyName_Fails() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_EmptyName_Fails), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp = AddFunctionParameter(user, ms, ft, "P"); + + Assert.IsFalse(ms.RenameFunctionParameter(user, ft, fp, "", out var err)); + Assert.IsNotNull(err); + Assert.AreEqual("P", fp.Name, "Name must not change on failure."); + }); + } + + /// A whitespace-only name is rejected. + [TestMethod] + public void RenameFunctionParameter_WhitespaceName_Fails() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_WhitespaceName_Fails), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp = AddFunctionParameter(user, ms, ft, "P"); + + Assert.IsFalse(ms.RenameFunctionParameter(user, ft, fp, " ", out var err)); + Assert.IsNotNull(err); + Assert.AreEqual("P", fp.Name); + }); + } + + /// Renaming to the same name as another parameter on the template is rejected. + [TestMethod] + public void RenameFunctionParameter_DuplicateName_Fails() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_DuplicateName_Fails), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp1 = AddFunctionParameter(user, ms, ft, "Alpha"); + var fp2 = AddFunctionParameter(user, ms, ft, "Beta"); + + Assert.IsFalse(ms.RenameFunctionParameter(user, ft, fp2, "Alpha", out var err), + "Renaming 'Beta' to 'Alpha' should fail – name already exists."); + Assert.IsNotNull(err); + Assert.AreEqual("Beta", fp2.Name, "Name must not change on failure."); + }); + } + + // ── Hook name propagation ────────────────────────────────────────────── + + /// + /// After a rename the on every + /// of the template must reflect the new name. + /// + [TestMethod] + public void RenameFunctionParameter_HookNameUpdated_OnAllInstances() + { + TestHelper.RunInModelSystemContext( + nameof(RenameFunctionParameter_HookNameUpdated_OnAllInstances), + (user, pSession, ms) => + { + var gb = ms.ModelSystem.GlobalBoundary; + var ft = AddTemplate(user, ms, gb); + var fp = AddFunctionParameter(user, ms, ft, "OldName"); + var fi1 = AddFunctionInstance(user, ms, gb, ft, "FI1"); + var fi2 = AddFunctionInstance(user, ms, gb, ft, "FI2"); + + Assert.IsTrue(ms.RenameFunctionParameter(user, ft, fp, "NewName", out var err), + err?.Message); + + // FunctionParameterHook names are lazily rebuilt, so re-query Hooks each time. + Assert.AreEqual("NewName", fi1.Hooks[0].Name, + "Hook name on FI1 must reflect the rename."); + Assert.AreEqual("NewName", fi2.Hooks[0].Name, + "Hook name on FI2 must reflect the rename."); + }); + } + + // ── Undo / redo ──────────────────────────────────────────────────────── + + /// Undo restores the original name. + [TestMethod] + public void RenameFunctionParameter_Undo_RestoresOldName() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_Undo_RestoresOldName), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp = AddFunctionParameter(user, ms, ft, "Original"); + + Assert.IsTrue(ms.RenameFunctionParameter(user, ft, fp, "Renamed", out _)); + Assert.AreEqual("Renamed", fp.Name); + + Assert.IsTrue(ms.Undo(user, out var undoErr), undoErr?.Message); + Assert.AreEqual("Original", fp.Name, "Undo must restore 'Original'."); + }); + } + + /// Redo re-applies the rename after undo. + [TestMethod] + public void RenameFunctionParameter_Redo_ReappliesRename() + { + TestHelper.RunInModelSystemContext(nameof(RenameFunctionParameter_Redo_ReappliesRename), + (user, pSession, ms) => + { + var ft = AddTemplate(user, ms, ms.ModelSystem.GlobalBoundary); + var fp = AddFunctionParameter(user, ms, ft, "Original"); + + Assert.IsTrue(ms.RenameFunctionParameter(user, ft, fp, "Renamed", out _)); + Assert.IsTrue(ms.Undo(user, out _)); + Assert.AreEqual("Original", fp.Name); + + Assert.IsTrue(ms.Redo(user, out var redoErr), redoErr?.Message); + Assert.AreEqual("Renamed", fp.Name, "Redo must re-apply 'Renamed'."); + }); + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctionParameters.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionParameters.cs new file mode 100644 index 0000000..c620869 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionParameters.cs @@ -0,0 +1,278 @@ +/* + 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.Linq; +using XTMF2.ModelSystemConstruct; +using XTMF2.Editing; + +namespace XTMF2.UnitTests.Editing +{ + [TestClass] + public class TestFunctionParameters + { + // ── Add / Remove ────────────────────────────────────────────────── + + [TestMethod] + public void TestAddFunctionParameter() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionParameter), (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.AddFunctionParameter(user, template, "MyParam", + typeof(XTMF2.IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + + Assert.IsNotNull(fp, "AddFunctionParameter should return a non-null parameter."); + Assert.AreEqual("MyParam", fp.Name); + Assert.AreEqual(typeof(XTMF2.IModule), fp.Type); + Assert.HasCount(1, template.FunctionParameters); + Assert.AreSame(template, fp.Template); + }); + } + + [TestMethod] + public void TestAddFunctionParameterUndo() + { + TestHelper.RunInModelSystemContext(nameof(TestAddFunctionParameterUndo), (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.AddFunctionParameter(user, template, "P1", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters); + + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "Undo should remove the FunctionParameter."); + + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters, "Redo should restore the FunctionParameter."); + }); + } + + [TestMethod] + public void TestRemoveFunctionParameter() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveFunctionParameter), (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.AddFunctionParameter(user, template, "P1", + typeof(XTMF2.IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + + Assert.IsTrue(mSession.RemoveFunctionParameter(user, template, fp, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "FunctionParameters should be empty after removal."); + }); + } + + [TestMethod] + public void TestRemoveFunctionParameterUndoRedo() + { + TestHelper.RunInModelSystemContext(nameof(TestRemoveFunctionParameterUndoRedo), (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.AddFunctionParameter(user, template, "P1", + typeof(XTMF2.IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + + Assert.IsTrue(mSession.RemoveFunctionParameter(user, template, fp, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters); + + // Undo: parameter comes back. + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters, "Undo should restore the FunctionParameter."); + + // Redo: parameter is removed again. + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "Redo should re-remove the FunctionParameter."); + }); + } + + // ── Unique-name enforcement ─────────────────────────────────────── + + [TestMethod] + public void TestFunctionParameterNamesAreUnique() + { + TestHelper.RunInModelSystemContext(nameof(TestFunctionParameterNamesAreUnique), (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.AddFunctionParameter(user, template, "P1", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + + // Adding a second parameter with the same name must fail. + Assert.IsFalse(mSession.AddFunctionParameter(user, template, "P1", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), + "Adding a duplicate FunctionParameter name should fail."); + Assert.IsNotNull(error, "An error should be set when the name is duplicate."); + }); + } + + [TestMethod] + public void TestFunctionParameterMultipleWithDifferentNames() + { + TestHelper.RunInModelSystemContext(nameof(TestFunctionParameterMultipleWithDifferentNames), (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.AddFunctionParameter(user, template, "A", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "B", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "C", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + + Assert.HasCount(3, template.FunctionParameters); + }); + } + + // ── FunctionInstance hooks reflect FunctionParameters ───────────── + + [TestMethod] + public void TestFunctionInstanceHooksReflectFunctionParameters() + { + TestHelper.RunInModelSystemContext(nameof(TestFunctionInstanceHooksReflectFunctionParameters), (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.AddFunctionInstance(user, ms.GlobalBoundary, template, "MyFI", + Rectangle.Hidden, out var fi, out error), error?.Message); + + // No parameters yet — FI should have no hooks. + Assert.IsEmpty(fi.Hooks, "FunctionInstance should start with no hooks when template has no parameters."); + + // Add two function parameters to the template. + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "Alpha", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "Beta", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + + Assert.HasCount(2, fi.Hooks, "FunctionInstance.Hooks should mirror the template's FunctionParameters."); + Assert.AreEqual("Alpha", fi.Hooks[0].Name); + Assert.AreEqual("Beta", fi.Hooks[1].Name); + }); + } + + [TestMethod] + public void TestFunctionInstanceHooksUpdateOnParameterRemoval() + { + TestHelper.RunInModelSystemContext(nameof(TestFunctionInstanceHooksUpdateOnParameterRemoval), (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + Assert.IsTrue(mSession.AddFunctionTemplate(user, ms.GlobalBoundary, "FT", + out FunctionTemplate template, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "X", + typeof(XTMF2.IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionInstance(user, ms.GlobalBoundary, template, "FI", + Rectangle.Hidden, out var fi, out error), error?.Message); + + Assert.HasCount(1, fi.Hooks); + + Assert.IsTrue(mSession.RemoveFunctionParameter(user, template, fp, out error), error?.Message); + Assert.IsEmpty(fi.Hooks, "Removing FunctionParameter should also remove the corresponding FI hook."); + }); + } + + // ── FunctionParameter is a valid link destination inside template ─ + + [TestMethod] + public void TestLinkToFunctionParameterInsideTemplate() + { + TestHelper.RunInModelSystemContext(nameof(TestLinkToFunctionParameterInsideTemplate), (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + Assert.IsTrue(mSession.AddFunctionTemplate(user, ms.GlobalBoundary, "FT", + out FunctionTemplate template, out error), error?.Message); + + // Add a FunctionParameter (valid link destination). + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "Input", + typeof(XTMF2.IModule), Rectangle.Hidden, out var fp, out error), error?.Message); + + // Add a node inside the template whose first hook accepts IModule. + Assert.IsTrue(mSession.AddNode(user, template.InternalModules, "Inner", + typeof(XTMF2.UnitTests.Modules.SimpleParameterModule), + Rectangle.Hidden, out var innerNode, out error), error?.Message); + + // Get the hook that accepts IFunction. + var hook = innerNode.Hooks?.FirstOrDefault(h => h.Name == "Real Function"); + Assert.IsNotNull(hook, "SimpleParameterModule should have a 'Real Function' hook."); + + // Link inner node's hook -> FunctionParameter (should succeed). + Assert.IsTrue(mSession.AddLink(user, innerNode, hook, fp, + out _, out error), error?.Message); + + // The link should now exist in the template's InternalModules. + Assert.IsTrue(template.InternalModules.Links.Any(l => l is SingleLink sl && sl.Destination == fp), + "InternalModules should contain a link whose destination is the FunctionParameter."); + }); + } + + // ── Save / Load roundtrip ───────────────────────────────────────── + + [TestMethod] + public void TestFunctionParameterSaveLoad() + { + TestHelper.RunInModelSystemContext(nameof(TestFunctionParameterSaveLoad), (user, pSession, mSession) => + { + CommandError error = null; + var ms = mSession.ModelSystem; + Assert.IsTrue(mSession.AddFunctionTemplate(user, ms.GlobalBoundary, "FT", + out FunctionTemplate template, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "Alpha", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + Assert.IsTrue(mSession.AddFunctionParameter(user, template, "Beta", + typeof(XTMF2.IModule), Rectangle.Hidden, out _, out error), error?.Message); + + // Save and reload the model system. + Assert.IsTrue(pSession.Save(out error), error?.Message); + }, + (user, pSession, mSession) => + { + var ms = mSession.ModelSystem; + var template = ms.GlobalBoundary.FunctionTemplates.FirstOrDefault(ft => ft.Name == "FT"); + Assert.IsNotNull(template, "FunctionTemplate 'FT' should survive save/load."); + Assert.HasCount(2, template.FunctionParameters, + "Both FunctionParameters should survive save/load."); + Assert.AreEqual("Alpha", template.FunctionParameters[0].Name); + Assert.AreEqual("Beta", template.FunctionParameters[1].Name); + Assert.AreEqual(typeof(XTMF2.IModule), template.FunctionParameters[0].Type); + }); + } + } +} 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..068cf29 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateEntryNode.cs @@ -0,0 +1,367 @@ +/* + 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."); + }); + } + + // ── 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] + 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/TestFunctionTemplateVariables.cs b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs new file mode 100644 index 0000000..9be30a4 --- /dev/null +++ b/tests/XTMF2.UnitTests/Editing/TestFunctionTemplateVariables.cs @@ -0,0 +1,614 @@ +/* + 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 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; + +[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."); + }); + } + + // ── 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); + }); + }); + } +} diff --git a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs index 5835620..c702ab0 100644 --- a/tests/XTMF2.UnitTests/Editing/TestFunctions.cs +++ b/tests/XTMF2.UnitTests/Editing/TestFunctions.cs @@ -98,6 +98,101 @@ public void TestRemoveFunctionTemplateUndo() }); } + [TestMethod] + public void TestRemoveFunctionParameterFromFunctionTemplate() + { + TestHelper.RunInModelSystemContext("TestRemoveFunctionParameterFromFunctionTemplate", (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.AddFunctionParameter(user, template, "MyParam", + typeof(XTMF2.IModule), Rectangle.Hidden, + out var fp, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters, "Parameter should exist before removal."); + + Assert.IsTrue(mSession.RemoveFunctionParameter(user, template, fp, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "FunctionParameters should be empty after removal."); + }); + } + + [TestMethod] + public void TestRemoveFunctionParameterFromFunctionTemplateUndoRedo() + { + TestHelper.RunInModelSystemContext("TestRemoveFunctionParameterFromFunctionTemplateUndoRedo", (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.AddFunctionParameter(user, template, "MyParam", + typeof(XTMF2.IModule), Rectangle.Hidden, + out var fp, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters, "Parameter should exist before removal."); + + Assert.IsTrue(mSession.RemoveFunctionParameter(user, template, fp, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "FunctionParameters should be empty after removal."); + + // Undo: parameter comes back. + Assert.IsTrue(mSession.Undo(user, out error), error?.Message); + Assert.HasCount(1, template.FunctionParameters, "Undo should restore the FunctionParameter."); + + // Redo: parameter is removed again. + Assert.IsTrue(mSession.Redo(user, out error), error?.Message); + Assert.IsEmpty(template.FunctionParameters, "Redo should re-remove the FunctionParameter."); + }); + } + + [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() { diff --git a/tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs b/tests/XTMF2.UnitTests/Editing/TestGhostNodeInFunctionTemplate.cs new file mode 100644 index 0000000..6944fad --- /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.Contains(realNode, gb.Modules, + "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.IsNotEmpty(hooks, "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."); + }); + } + } +} 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."); + }); + } } }