From 8c257ac77eea9a2370f9c7e3968eb59477fbb37a Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Tue, 10 Mar 2026 16:56:20 -0400 Subject: [PATCH] Added modules and UX for streaming context and returning values --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 31 +++ .../ViewModels/ModelSystemEditorViewModel.cs | 230 ++++++++++++++++++ .../RuntimeModules/ExecuteWithContext.cs | 40 +++ src/XTMF2/RuntimeModules/WithContext.cs | 56 +++++ 4 files changed, 357 insertions(+) create mode 100644 src/XTMF2/RuntimeModules/ExecuteWithContext.cs create mode 100644 src/XTMF2/RuntimeModules/WithContext.cs diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 4c4bff9..f2090c7 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -1867,6 +1867,37 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) menu.Items.Add(new Separator()); } + // ── IFunction → Create linked ExecuteWithContext ─────────────── + if (element is NodeViewModel funcNode) + { + var nodeType = funcNode.UnderlyingNode.Type; + var iFunctionOpen = typeof(IFunction<>); + Type? returnType = null; + if (nodeType is not null) + { + foreach (var iface in nodeType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == iFunctionOpen) + { + returnType = iface.GetGenericArguments()[0]; + break; + } + } + } + + if (returnType is not null) + { + var capturedFuncNode = funcNode; + var wrapItem = new MenuItem + { + Header = $"Create ExecuteWithContext<{returnType.Name}> (linked)" + }; + wrapItem.Click += (_, _) => _ = vm.CreateExecuteWithContextAsync(capturedFuncNode); + menu.Items.Add(wrapItem); + menu.Items.Add(new Separator()); + } + } + menu.Items.Add(deleteItem); ContextMenu = menu; diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 33d3930..9c26f55 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -689,6 +689,103 @@ await ShowError("No Compatible Types", await ShowError("Create Link Failed", linkError); } + /// + /// If implements IAction<Context>, returns + /// Context; otherwise returns null. + /// + private static Type? GetIActionContextType(Type? nodeType) + { + if (nodeType is null) return null; + var open = typeof(IAction<>); + foreach (var iface in nodeType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == open) + return iface.GetGenericArguments()[0]; + } + return null; + } + + /// + /// If implements IFunction<Context, Return>, + /// returns (contextType, returnType); otherwise returns null. + /// Only the first matching interface is returned. + /// + private static (Type Context, Type Return)? GetIFunction2Types(Type? nodeType) + { + if (nodeType is null) return null; + var open = typeof(IFunction<,>); + foreach (var iface in nodeType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == open) + { + var args = iface.GetGenericArguments(); + return (args[0], args[1]); + } + } + return null; + } + + /// + /// If implements IFunction<T>, returns T; + /// otherwise returns null. + /// + private static Type? GetIFunctionReturnType(Type? nodeType) + { + if (nodeType is null) return null; + var iFunctionOpen = typeof(IFunction<>); + foreach (var iface in nodeType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == iFunctionOpen) + return iface.GetGenericArguments()[0]; + } + return null; + } + + /// + /// Creates a new node whose generic parameter + /// matches the return type T that 's module implements + /// via IFunction<T>. The new node is positioned to the right of + /// and its Context hook is linked back to the source. + /// + public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode) + { + if (ParentWindow is null) return; + + var returnType = GetIFunctionReturnType(sourceNode.UnderlyingNode.Type); + if (returnType is null) return; + + var executeWithContextType = typeof(Execute<>).MakeGenericType(returnType); + + var nameDialog = new InputDialog( + title: $"Add Execute With Context<{returnType.Name}> Module", + prompt: "Enter module name:", + defaultText: executeWithContextType.Name); + await nameDialog.ShowDialog(ParentWindow); + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + const float PlacementGap = 30f; + var location = new Rectangle( + sourceNode.UnderlyingNode.Location.X + sourceNode.UnderlyingNode.Location.Width + PlacementGap, + sourceNode.UnderlyingNode.Location.Y, + 120f, 50f); + + if (!Session.AddNodeGenerateParameters(User, _currentBoundary, name, executeWithContextType, + location, out var newNode, out _, out var nodeError)) + { + await ShowError("Add Module Failed", nodeError); + return; + } + + // Find the "Context" hook on the new ExecuteWithContext node and link it to sourceNode. + var contextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Context"); + if (contextHook is not null) + { + if (!Session.AddLink(User, newNode, contextHook, sourceNode.UnderlyingNode, out _, out var linkError)) + await ShowError("Create Link Failed", linkError); + } + } + /// /// Attempt to create a link from to . /// If any compatible hooks are found, either uses the sole hook automatically or @@ -723,6 +820,30 @@ public async Task CreateLinkAsync(ICanvasElement originElement, NodeViewModel de if (compatible.Count == 0) { + // Special case: origin implements IFunction and destination + // implements IFunction → inject a ReturnUsingContext. + if (originElement is NodeViewModel originNvm + && GetIFunction2Types(originNode.Type) is { } f2 + && destType is not null + && typeof(IFunction<>).MakeGenericType(f2.Context).IsAssignableFrom(destType)) + { + await InjectReturnUsingContextAsync(originNvm, destVm, f2.Context, f2.Return); + return; + } + + // Special case: origin implements IAction and destination + // implements IFunction where ContextBase.IsAssignableFrom(ContextDerived) + // → inject a WithContext. + if (originElement is NodeViewModel originNvmAction + && GetIActionContextType(originNode.Type) is { } contextBase + && destType is not null + && GetIFunctionReturnType(destType) is { } contextDerived + && contextBase.IsAssignableFrom(contextDerived)) + { + await InjectWithContextAsync(originNvmAction, destVm, contextBase, contextDerived); + return; + } + await ShowError("Incompatible Types", new CommandError($"No hooks on '{originNode.Name}' are compatible with type '{destType?.Name ?? "Unknown"}'.")); return; @@ -747,6 +868,115 @@ await ShowError("Incompatible Types", // On success the boundary CollectionChanged fires and TryAddLinkViewModel wires up the new link. } + /// + /// Injects a node between + /// (an IFunction<Context,Return> provider) + /// and (an IFunction<Context> provider). + /// The injected node is placed to the right of the origin. + /// + private async Task InjectReturnUsingContextAsync( + NodeViewModel originNode, NodeViewModel contextNode, Type contextType, Type returnType) + { + if (ParentWindow is null) return; + + var injectedType = typeof(ReturnUsingContext<,>).MakeGenericType(contextType, returnType); + + var nameDialog = new InputDialog( + title: $"Add ReturnUsingContext<{contextType.Name},{returnType.Name}> Module", + prompt: "Enter module name:", + defaultText: $"Return {returnType.Name} Using {contextType.Name}"); + await nameDialog.ShowDialog(ParentWindow); + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + const float PlacementGap = 30f; + var location = new Rectangle( + originNode.UnderlyingNode.Location.X + originNode.UnderlyingNode.Location.Width + PlacementGap, + originNode.UnderlyingNode.Location.Y, + 120f, 50f); + + if (!Session.AddNodeGenerateParameters(User, _currentBoundary, name, injectedType, + location, out var newNode, out _, out var nodeError)) + { + await ShowError("Add Module Failed", nodeError); + return; + } + + // Wire "To Execute" hook → originNode (IFunction) + var toExecuteHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "To Execute"); + if (toExecuteHook is not null) + { + if (!Session.AddLink(User, newNode, toExecuteHook, originNode.UnderlyingNode, out _, out var le1)) + { + await ShowError("Create Link Failed", le1); + return; + } + } + + // Wire "Get Context" hook → contextNode (IFunction) + var getContextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Get Context"); + if (getContextHook is not null) + { + if (!Session.AddLink(User, newNode, getContextHook, contextNode.UnderlyingNode, out _, out var le2)) + await ShowError("Create Link Failed", le2); + } + } + + /// + /// Injects a node between + /// (an IAction<ContextBase> consumer) and + /// (an IFunction<ContextDerived> provider + /// where ContextDerived derives from / implements ContextBase). + /// The injected node is placed to the right of the origin. + /// + private async Task InjectWithContextAsync( + NodeViewModel originNode, NodeViewModel contextNode, Type contextBase, Type contextDerived) + { + if (ParentWindow is null) return; + + var injectedType = typeof(WithContext<,>).MakeGenericType(contextDerived, contextBase); + + var nameDialog = new InputDialog( + title: $"Add WithContext<{contextDerived.Name},{contextBase.Name}> Module", + prompt: "Enter module name:", + defaultText: $"With {contextDerived.Name} As {contextBase.Name}"); + await nameDialog.ShowDialog(ParentWindow); + var name = nameDialog.InputText?.Trim(); + if (string.IsNullOrEmpty(name) || nameDialog.WasCancelled) return; + + const float PlacementGap = 30f; + var location = new Rectangle( + originNode.UnderlyingNode.Location.X + originNode.UnderlyingNode.Location.Width + PlacementGap, + originNode.UnderlyingNode.Location.Y, + 120f, 50f); + + if (!Session.AddNodeGenerateParameters(User, _currentBoundary, name, injectedType, + location, out var newNode, out _, out var nodeError)) + { + await ShowError("Add Module Failed", nodeError); + return; + } + + // Wire "To Execute" hook → originNode (IAction) + var toExecuteHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "To Execute"); + if (toExecuteHook is not null) + { + if (!Session.AddLink(User, newNode, toExecuteHook, originNode.UnderlyingNode, out _, out var le1)) + { + await ShowError("Create Link Failed", le1); + return; + } + } + + // Wire "Get Context" hook → contextNode (IFunction) + var getContextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Get Context"); + if (getContextHook is not null) + { + if (!Session.AddLink(User, newNode, getContextHook, contextNode.UnderlyingNode, out _, out var le2)) + await ShowError("Create Link Failed", le2); + } + } + /// /// Presents a two-phase dialog that lets the user pick a boundary, then a compatible node /// within that boundary, and creates a link from 's diff --git a/src/XTMF2/RuntimeModules/ExecuteWithContext.cs b/src/XTMF2/RuntimeModules/ExecuteWithContext.cs new file mode 100644 index 0000000..7b16fb2 --- /dev/null +++ b/src/XTMF2/RuntimeModules/ExecuteWithContext.cs @@ -0,0 +1,40 @@ +/* + 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 . +*/ + +namespace XTMF2.RuntimeModules; + +[Module(Name = "Execute With Context", DocumentationLink = "http://tmg.utoronto.ca/doc/2.0", + Description = "Provides a way to execute a series of actions with a context loaded from the provided context.")] +public sealed class ExecuteWithContext : BaseAction +{ + [SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)] + public IFunction GetContext = null!; + + [SubModule(Required = true, Name = "To Execute", Description = "The actions to execute with the context.", Index = 1)] + public IAction[] ToInvoke = null!; + + override public void Invoke() + { + var context = GetContext.Invoke(); + foreach (var action in ToInvoke!) + { + action.Invoke(context); + } + } +} diff --git a/src/XTMF2/RuntimeModules/WithContext.cs b/src/XTMF2/RuntimeModules/WithContext.cs new file mode 100644 index 0000000..4beda68 --- /dev/null +++ b/src/XTMF2/RuntimeModules/WithContext.cs @@ -0,0 +1,56 @@ +/* + 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.Runtime.InteropServices; + +namespace XTMF2.RuntimeModules; + +[Module(Name = "With Context", DocumentationLink = "http://tmg.utoronto.ca/doc/2.0", + Description = "Provides a way to execute an action with a context loaded from the provided context.")] +public sealed class WithContext : BaseAction where Context1 : Context2 +{ + [SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)] + public IFunction GetContext = null!; + + [SubModule(Required = true, Name = "To Execute", Description = "The action to execute with the context.", Index = 1)] + public IAction ToInvoke = null!; + + override public void Invoke() + { + var context = GetContext.Invoke(); + ToInvoke.Invoke(context); + } +} + +[Module(Name = "Return Using Context", DocumentationLink = "http://tmg.utoronto.ca/doc/2.0", + Description = "Provides a way to execute a function with a context loaded from the provided context and return the result.")] +public sealed class ReturnUsingContext : BaseFunction +{ + [SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)] + public IFunction GetContext = null!; + + [SubModule(Required = true, Name = "To Execute", Description = "The function to execute with the context.", Index = 1)] + public IFunction ToInvoke = null!; + + override public Return Invoke() + { + var context = GetContext.Invoke(); + return ToInvoke.Invoke(context); + } +}