Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,37 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link)
menu.Items.Add(new Separator());
}

// ── IFunction<T> → 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;
Expand Down
230 changes: 230 additions & 0 deletions src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,103 @@ await ShowError("No Compatible Types",
await ShowError("Create Link Failed", linkError);
}

/// <summary>
/// If <paramref name="nodeType"/> implements <c>IAction&lt;Context&gt;</c>, returns
/// <c>Context</c>; otherwise returns <c>null</c>.
/// </summary>
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;
}

/// <summary>
/// If <paramref name="nodeType"/> implements <c>IFunction&lt;Context, Return&gt;</c>,
/// returns <c>(contextType, returnType)</c>; otherwise returns <c>null</c>.
/// Only the first matching interface is returned.
/// </summary>
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;
}

/// <summary>
/// If <paramref name="nodeType"/> implements <c>IFunction&lt;T&gt;</c>, returns <c>T</c>;
/// otherwise returns <c>null</c>.
/// </summary>
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;
}

/// <summary>
/// Creates a new <see cref="Execute{T}"/> node whose generic parameter
/// matches the return type <c>T</c> that <paramref name="sourceNode"/>'s module implements
/// via <c>IFunction&lt;T&gt;</c>. The new node is positioned to the right of
/// <paramref name="sourceNode"/> and its <em>Context</em> hook is linked back to the source.
/// </summary>
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);
}
}

/// <summary>
/// Attempt to create a link from <paramref name="originElement"/> to <paramref name="destVm"/>.
/// If any compatible hooks are found, either uses the sole hook automatically or
Expand Down Expand Up @@ -723,6 +820,30 @@ public async Task CreateLinkAsync(ICanvasElement originElement, NodeViewModel de

if (compatible.Count == 0)
{
// Special case: origin implements IFunction<Context,Return> and destination
// implements IFunction<Context> → inject a ReturnUsingContext<Context,Return>.
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<ContextBase> and destination
// implements IFunction<ContextDerived> where ContextBase.IsAssignableFrom(ContextDerived)
// → inject a WithContext<ContextDerived, ContextBase>.
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;
Expand All @@ -747,6 +868,115 @@ await ShowError("Incompatible Types",
// On success the boundary CollectionChanged fires and TryAddLinkViewModel wires up the new link.
}

/// <summary>
/// Injects a <see cref="ReturnUsingContext{Context,Return}"/> node between
/// <paramref name="originNode"/> (an <c>IFunction&lt;Context,Return&gt;</c> provider)
/// and <paramref name="contextNode"/> (an <c>IFunction&lt;Context&gt;</c> provider).
/// The injected node is placed to the right of the origin.
/// </summary>
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<Context,Return>)
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<Context>)
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);
}
}

/// <summary>
/// Injects a <see cref="WithContext{ContextDerived, ContextBase}"/> node between
/// <paramref name="originNode"/> (an <c>IAction&lt;ContextBase&gt;</c> consumer) and
/// <paramref name="contextNode"/> (an <c>IFunction&lt;ContextDerived&gt;</c> provider
/// where <c>ContextDerived</c> derives from / implements <c>ContextBase</c>).
/// The injected node is placed to the right of the origin.
/// </summary>
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<ContextBase>)
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<ContextDerived>)
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);
}
}

/// <summary>
/// Presents a two-phase dialog that lets the user pick a boundary, then a compatible node
/// within that boundary, and creates a link from <paramref name="originNode"/>'s
Expand Down
40 changes: 40 additions & 0 deletions src/XTMF2/RuntimeModules/ExecuteWithContext.cs
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<Context> : BaseAction
{
[SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)]
public IFunction<Context> GetContext = null!;

[SubModule(Required = true, Name = "To Execute", Description = "The actions to execute with the context.", Index = 1)]
public IAction<Context>[] ToInvoke = null!;

override public void Invoke()
{
var context = GetContext.Invoke();
foreach (var action in ToInvoke!)
{
action.Invoke(context);
}
}
}
56 changes: 56 additions & 0 deletions src/XTMF2/RuntimeModules/WithContext.cs
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<Context1, Context2> : BaseAction where Context1 : Context2
{
[SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)]
public IFunction<Context1> GetContext = null!;

[SubModule(Required = true, Name = "To Execute", Description = "The action to execute with the context.", Index = 1)]
public IAction<Context2> 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<Context, Return> : BaseFunction<Return>
{
[SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)]
public IFunction<Context> GetContext = null!;

[SubModule(Required = true, Name = "To Execute", Description = "The function to execute with the context.", Index = 1)]
public IFunction<Context, Return> ToInvoke = null!;

override public Return Invoke()
{
var context = GetContext.Invoke();
return ToInvoke.Invoke(context);
}
}