diff --git a/Coder.Graph/AstGraph.cs b/Coder.Graph/AstGraph.cs
index 1eea4c2..4b0bcac 100644
--- a/Coder.Graph/AstGraph.cs
+++ b/Coder.Graph/AstGraph.cs
@@ -90,6 +90,33 @@ public AstGraph(AstNode root)
/// The AST node, or null if the id is not in this graph.
public AstNode? AstNodeFor(int nodeId) => nodesById.TryGetValue(nodeId, out AstNode? node) ? node : null;
+ ///
+ /// Finds the node an output pin belongs to.
+ ///
+ /// The pin's id.
+ /// The node the pin hangs off, or null if the id is not an output pin of this graph.
+ ///
+ /// Paired with so a caller holding one pin id can tell which end
+ /// of a connection it is, which is what a gesture that started at a pin and ended nowhere needs
+ /// before it can say what a new node would be attached to.
+ ///
+ public AstNode? OwnerOfOutputPin(int outputPinId) =>
+ ownerByOutputPin.TryGetValue(outputPinId, out AstNode? owner) ? owner : null;
+
+ ///
+ /// Finds the place in the document an input pin stands for.
+ ///
+ /// The pin's id.
+ /// The slot the pin fills, or null if the id is not an input pin of this graph.
+ ///
+ /// Returns an rather than the pin record itself, because a location names
+ /// nodes and outlives the rebuild that reassigns every pin id.
+ ///
+ public AstLocation? LocationOfInputPin(int inputPinId) =>
+ slotByInputPin.TryGetValue(inputPinId, out SlotPin? pin)
+ ? new AstLocation(pin.Parent, pin.Slot, pin.Index)
+ : null;
+
///
/// Rebuilds the engine graph from the current AST, preserving on-screen positions.
///
diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs
index 62c9e81..4109429 100644
--- a/Coder.Graph/AstGraphEditor.cs
+++ b/Coder.Graph/AstGraphEditor.cs
@@ -62,6 +62,11 @@ public sealed class AstGraphEditor(AstNode root)
DoubleFormat = "%.17g",
};
+ ///
+ /// The popup offering to create a node for a link dropped on empty canvas.
+ ///
+ private const string LinkDropPopup = "ast-graph-link-drop";
+
private string statusMessage = string.Empty;
private string fieldBuffer = string.Empty;
@@ -70,6 +75,25 @@ public sealed class AstGraphEditor(AstNode root)
private string? editingField;
+ ///
+ /// The pin a link was dragged off and dropped on empty canvas, while the menu offering to create a
+ /// node for it is open.
+ ///
+ ///
+ /// Held across frames because the drop and the choice happen in different ones: ImNodes reports the
+ /// drop once, and the menu it opens is answered some frames later. Null when no such menu is open.
+ ///
+ private int? linkDropPin;
+
+ ///
+ /// Whether the menu for still has to be opened.
+ ///
+ ///
+ /// Separate from the pin because only means anything inside
+ /// a frame, and the request to offer the menu can arrive from outside one.
+ ///
+ private bool linkDropOpening;
+
///
/// Gets the graph being edited.
///
@@ -197,6 +221,7 @@ public void Draw(Vector2 size, float deltaTime)
TrackSelection();
ApplyDeletions();
DrawPalette();
+ DrawLinkDropMenu();
if (ShowDebugOverlays)
{
@@ -778,6 +803,14 @@ private void ApplyLinkChanges()
{
Disconnect(destroyedLink);
}
+
+ // A drag released over empty canvas. ImNodes has already thrown the link away, so there is
+ // nothing dangling to clean up if the user then dismisses the menu.
+ int droppedFrom = 0;
+ if (ImNodes.IsLinkDropped(ref droppedFrom, includingDetachedLinks: false))
+ {
+ RequestCreateFrom(droppedFrom);
+ }
}
///
@@ -1022,6 +1055,157 @@ public IEnumerable ConversionsFor(AstNode node)
slot is null || AstSchema.Accepts(slot, template.Create()));
}
+ ///
+ /// Lists the kinds of node a link dragged off a pin and dropped on empty canvas could create.
+ ///
+ /// The pin the drag started at.
+ /// The palette entries that would connect to that pin, in palette order.
+ ///
+ /// Which way the pin faces decides what is on offer, because the new node takes the other end of
+ /// the connection. Dropping a drag that began at an input pin asks for something to fill that
+ /// slot, so the entries are the ones the slot accepts. Dropping one that began at an output pin
+ /// asks for somewhere to put that node, so the entries are the ones with a slot that accepts it.
+ ///
+ /// The same the manual drag is checked against decides both, so a
+ /// node offered here cannot be one the connection would then refuse. An empty result is a real
+ /// answer rather than a failure: a statement dragged out of a body has nowhere in the catalogue to
+ /// go, and the menu says so instead of listing entries that would not take it.
+ ///
+ ///
+ public IEnumerable CreatableFrom(int pinId)
+ {
+ AstNode? dragged = Graph.OwnerOfOutputPin(pinId);
+ if (dragged is not null)
+ {
+ return AstNodeCatalog.Templates.Where(template => SlotAccepting(template.Create(), dragged) is not null);
+ }
+
+ AstSlot? slot = Graph.LocationOfInputPin(pinId)?.Slot;
+ return slot is null
+ ? []
+ : AstNodeCatalog.Templates.Where(template => AstSchema.Accepts(slot, template.Create()));
+ }
+
+ ///
+ /// Offers the create-node menu for a pin, the way releasing a link drag over empty canvas does.
+ ///
+ /// The pin to offer nodes for.
+ /// True if the pin is one this graph can offer nodes for, so the menu will open.
+ ///
+ /// Separate from the gesture that usually triggers it, so the affordance is an operation rather
+ /// than something only a mouse can reach — a host binding it to a key wants the same menu, and it
+ /// can be driven in a test without a drag landing on a pin by luck.
+ ///
+ /// Only remembers the request. The popup itself is opened on the next frame drawn, because
+ /// only means anything inside one.
+ ///
+ ///
+ public bool RequestCreateFrom(int pinId)
+ {
+ if (Graph.OwnerOfOutputPin(pinId) is null && Graph.LocationOfInputPin(pinId)?.Slot is null)
+ {
+ statusMessage = "That pin is not in this graph.";
+ return false;
+ }
+
+ linkDropPin = pinId;
+ linkDropOpening = true;
+ return true;
+ }
+
+ ///
+ /// Creates a node from a template and connects it to the pin a link was dragged off.
+ ///
+ /// The kind of node to create.
+ /// The pin the drag started at.
+ /// Where to place the new node.
+ /// True if the node was created and connected.
+ ///
+ /// Two steps go on the history rather than one, the same way creating a node from the palette and
+ /// wiring it up by hand would: the user did both, and undoing the connection without also undoing
+ /// the node is a state they can reach by hand too.
+ ///
+ /// Refuses rather than creating an orphan if the pin is not in the graph, or if the template turns
+ /// out not to connect after all. Nothing is recorded in that case, so a refusal leaves the history
+ /// alone.
+ ///
+ ///
+ public bool CreateFrom(AstNodeTemplate template, int pinId, Vector2 position)
+ {
+ Ensure.NotNull(template);
+
+ AstNode? dragged = Graph.OwnerOfOutputPin(pinId);
+ if (dragged is not null)
+ {
+ // The drag began at a node's output, so the new node is the parent and the dragged node
+ // moves into it.
+ AstNode parent = template.Create();
+ AstSlot? slot = SlotAccepting(parent, dragged);
+ if (slot is null)
+ {
+ statusMessage = $"{AstSchema.Describe(parent)} has nowhere to put {AstSchema.Describe(dragged)}.";
+ return false;
+ }
+
+ AstLocation from = Graph.LocationOf(dragged);
+ Add(parent, position);
+
+ int index = slot.Cardinality == AstSlotCardinality.Many
+ ? AstSchema.ChildrenOf(parent, slot).Count
+ : 0;
+
+ RecordMove(
+ $"Connect {AstSchema.Describe(dragged)} to {slot.Name} of {AstSchema.Describe(parent)}",
+ ChangeType.Move,
+ dragged,
+ new AstLocation(parent, slot, index),
+ from);
+
+ statusMessage = $"Created {AstSchema.Describe(parent)} around {AstSchema.Describe(dragged)}.";
+ return true;
+ }
+
+ AstLocation? target = Graph.LocationOfInputPin(pinId);
+ if (target?.Slot is null)
+ {
+ statusMessage = "That pin is not in this graph.";
+ return false;
+ }
+
+ // The drag began at a slot, so the new node is the child that fills it.
+ AstNode child = template.Create();
+ if (!AstSchema.Accepts(target.Value.Slot, child))
+ {
+ statusMessage = $"{AstSchema.Describe(child)} cannot fill {target.Value.Slot.Name}.";
+ return false;
+ }
+
+ Add(child, position);
+ RecordMove(
+ $"Connect {AstSchema.Describe(child)} to {target.Value.Slot.Name}",
+ ChangeType.Move,
+ child,
+ target.Value,
+ Graph.LocationOf(child));
+
+ statusMessage = $"Created {AstSchema.Describe(child)} in {target.Value.Slot.Name}.";
+ return true;
+ }
+
+ ///
+ /// Finds the first slot of a node that would take a given child.
+ ///
+ /// The node to look for a slot on.
+ /// The node that has to fit.
+ /// The slot, or null if none of them would take it.
+ ///
+ /// First rather than best: a freshly created template node has no real children yet, so every slot
+ /// that accepts the child is equally empty, and the first is the one the palette's own ordering
+ /// puts first.
+ ///
+ private static AstSlot? SlotAccepting(AstNode parent, AstNode child) =>
+ AstSchema.SlotsOf(parent).FirstOrDefault(slot => AstSchema.Accepts(slot, child));
+
///
/// Adds one more child to a variadic slot, as one undoable edit.
///
@@ -1259,4 +1443,175 @@ private void DrawPalette()
ImGui.EndPopup();
}
+
+ ///
+ /// Draws the menu a link dropped on empty canvas opens, and creates whatever the user picks.
+ ///
+ ///
+ /// Grouped the same way the palette is, minus the categories and submenus nothing in them would
+ /// connect, so the menu is a shortlist rather than the whole catalogue with most of it refusing.
+ ///
+ /// Dismissing the menu without choosing leaves the graph untouched: ImNodes discards a dropped link
+ /// itself, so there is no half-made connection to undo — only the remembered pin to forget.
+ ///
+ ///
+ private void DrawLinkDropMenu()
+ {
+ if (linkDropPin is not int pinId)
+ {
+ return;
+ }
+
+ // Opened here rather than where the request came from, because ImGui.OpenPopup only means
+ // anything inside a frame and a request can arrive from outside one.
+ if (linkDropOpening)
+ {
+ ImGui.OpenPopup(LinkDropPopup);
+ linkDropOpening = false;
+ }
+
+ if (!ImGui.BeginPopup(LinkDropPopup))
+ {
+ // Open exactly as long as the popup is: once it has gone, the user either picked something
+ // or dismissed it, and either way the pin is spent.
+ linkDropPin = null;
+ return;
+ }
+
+ Vector2 dropPosition = ImGui.GetMousePosOnOpeningCurrentPopup();
+ HashSet creatable = [.. CreatableFrom(pinId)];
+
+ if (creatable.Count == 0)
+ {
+ ImGui.TextDisabled(NothingConnects(pinId));
+ ImGuiProbes.MarkItem("Nothing connects");
+ ImGui.EndPopup();
+ return;
+ }
+
+ foreach (string category in AstNodeCatalog.Categories)
+ {
+ DrawLinkDropCategory(category, creatable, pinId, dropPosition);
+ }
+
+ ImGui.EndPopup();
+ }
+
+ ///
+ /// Draws one category of the dropped-link menu, and the submenus inside it.
+ ///
+ /// The category to draw.
+ /// The entries that would connect to the pin.
+ /// The pin the link was dragged off.
+ /// Where a created node is placed.
+ ///
+ /// A category with nothing on offer is left out rather than drawn empty, which is what makes this
+ /// menu a shortlist rather than the whole palette with most of it refusing.
+ ///
+ private void DrawLinkDropCategory(
+ string category,
+ HashSet creatable,
+ int pinId,
+ Vector2 dropPosition)
+ {
+ if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains))
+ {
+ return;
+ }
+
+ bool open = ImGui.BeginMenu(category);
+
+ // Named for the probes the way the palette's rows are, so a test opens a category by the label
+ // the user reads rather than by a coordinate.
+ ImGuiProbes.MarkItem($"Create {category}");
+
+ if (!open)
+ {
+ return;
+ }
+
+ DrawLinkDropEntries(AstNodeCatalog.InGroup(category, null).Where(creatable.Contains), pinId, dropPosition);
+
+ foreach (string group in AstNodeCatalog.GroupsIn(category))
+ {
+ DrawLinkDropGroup(category, group, creatable, pinId, dropPosition);
+ }
+
+ ImGui.EndMenu();
+ }
+
+ ///
+ /// Draws one submenu of a category in the dropped-link menu.
+ ///
+ /// The category the submenu sits under.
+ /// The submenu to draw.
+ /// The entries that would connect to the pin.
+ /// The pin the link was dragged off.
+ /// Where a created node is placed.
+ private void DrawLinkDropGroup(
+ string category,
+ string group,
+ HashSet creatable,
+ int pinId,
+ Vector2 dropPosition)
+ {
+ AstNodeTemplate[] entries = [.. AstNodeCatalog.InGroup(category, group).Where(creatable.Contains)];
+ if (entries.Length == 0)
+ {
+ return;
+ }
+
+ bool open = ImGui.BeginMenu(group);
+ ImGuiProbes.MarkItem($"Create {category} {group}");
+
+ if (open)
+ {
+ DrawLinkDropEntries(entries, pinId, dropPosition);
+ ImGui.EndMenu();
+ }
+ }
+
+ ///
+ /// Draws one menu's worth of entries for a dropped link, connecting whatever is picked.
+ ///
+ /// The entries to list.
+ /// The pin the link was dragged off.
+ /// Where a created node is placed.
+ private void DrawLinkDropEntries(IEnumerable templates, int pinId, Vector2 dropPosition)
+ {
+ foreach (AstNodeTemplate template in templates)
+ {
+ bool picked = ImGui.MenuItem(template.Label);
+ ImGuiProbes.MarkItem($"Create {template.Label}");
+
+ if (!picked)
+ {
+ continue;
+ }
+
+ CreateFrom(template, pinId, dropPosition);
+ linkDropPin = null;
+ ImGui.CloseCurrentPopup();
+ return;
+ }
+ }
+
+ ///
+ /// Says why a dropped link has nothing to offer, in terms of what the user dragged.
+ ///
+ /// The pin the link was dragged off.
+ /// The message the menu shows in place of entries.
+ private string NothingConnects(int pinId)
+ {
+ AstNode? dragged = Graph.OwnerOfOutputPin(pinId);
+ if (dragged is not null)
+ {
+ return $"Nothing in the palette takes {AstSchema.Describe(dragged)}.";
+ }
+
+ AstSlot? slot = Graph.LocationOfInputPin(pinId)?.Slot;
+ return slot is null
+ ? "That pin is no longer in the graph."
+ : $"Nothing in the palette fills {slot.Name}.";
+ }
}
diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs
new file mode 100644
index 0000000..3723db9
--- /dev/null
+++ b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs
@@ -0,0 +1,240 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Graph;
+
+using System.Linq;
+using System.Numerics;
+using Hexa.NET.ImGui;
+using ktsu.Coder.Ast;
+using ktsu.Coder.Graph;
+using ktsu.ImGui.App;
+using ktsu.ImGui.App.Testing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers the menu a dropped link opens as the user meets it: entries reached with the mouse, by the
+/// label they read as, through the headless rasterizer.
+///
+///
+/// What the entries are and what picking one attaches is covered headlessly in
+/// . What is covered here is that the popup draws those
+/// entries at all and that clicking one reaches the edit — the half that only a rendered frame can
+/// establish, since ImGui menus fault inside native code rather than throwing when misnested.
+///
+/// The menu is opened through rather than by dragging
+/// a link onto a pin, because where a pin lands on screen depends on the force-directed layout. The
+/// drag itself is covered by Editor_HandlesALinkDroppedOnEmptyCanvas.
+///
+///
+/// ImGui contexts are process-global, so only one harness can be live at a time and this class must
+/// not run its methods in parallel.
+///
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class AstGraphEditorLinkDropMenuTests
+{
+ private static readonly HarnessOptions Options = new() { Width = 1280, Height = 800 };
+
+ private static FunctionDeclaration SampleFunction()
+ {
+ FunctionDeclaration function = new("total") { ReturnType = "int" };
+ function.Parameters.Add(new Parameter("a", "int"));
+ function.Body.Add(new ReturnStatement(
+ new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, Literal.Number(1))));
+ return function;
+ }
+
+ private static ImGuiAppConfig ConfigFor(AstGraphEditor editor) => new()
+ {
+ Title = "AST link drop",
+ OnRender = delta =>
+ {
+ ImGui.Begin("graph");
+ editor.Draw(new Vector2(1000, 600), delta);
+ ImGui.End();
+ },
+ };
+
+ private static bool IsVisible(ImGuiAppHarness harness, string name) =>
+ harness.Probe.WasSeenInFrame(name, harness.FrameCount - 1);
+
+ ///
+ /// Starts a harness with the create-node menu already open for a pin.
+ ///
+ /// The editor to draw.
+ /// The pin to offer nodes for.
+ /// The running harness, for the caller to dispose.
+ private static ImGuiAppHarness Offering(AstGraphEditor editor, int pinId)
+ {
+ ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options);
+ harness.Step(2);
+
+ Assert.IsTrue(editor.RequestCreateFrom(pinId), "the pin to offer nodes for should be in the graph");
+ harness.Step(2);
+ return harness;
+ }
+
+ ///
+ /// Tests that the menu lists a category per group of entries that would connect, and leaves out
+ /// the ones that filtered empty.
+ ///
+ [TestMethod]
+ public void Menu_ListsOnlyTheCategoriesThatHaveSomethingToOffer()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ // The Parameters slot takes only a parameter, which lives under Declarations.
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1));
+
+ Assert.IsTrue(IsVisible(harness, "Create Declarations"), "the menu should offer Declarations");
+ Assert.IsFalse(IsVisible(harness, "Create Literals"), "no literal can fill a Parameters slot");
+ Assert.IsFalse(IsVisible(harness, "Nothing connects"), "a parameter is creatable, so the menu is not empty");
+ }
+
+ ///
+ /// Tests that clicking an entry creates the node and connects it, through the menu rather than by
+ /// calling the edit directly.
+ ///
+ [TestMethod]
+ public void Menu_CreatesAndConnectsThePickedEntry()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1));
+
+ harness.Click("Create Declarations");
+ harness.Step(2);
+
+ harness.Click("Create Parameter");
+ harness.Step(2);
+
+ Assert.HasCount(2, function.Parameters);
+ Assert.IsTrue(editor.History.CanUndo, "creating through the menu should be undoable");
+ }
+
+ ///
+ /// Tests that a slot the palette cannot fill says so, rather than opening an empty menu.
+ ///
+ [TestMethod]
+ public void Menu_SaysSoWhenNothingConnects()
+ {
+ EnumDeclaration enumeration = new("Colour");
+ AstGraphEditor editor = new(enumeration);
+
+ AstSlot members = AstSchema.SlotsOf(enumeration).Single();
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, enumeration, members.Name, 0));
+
+ Assert.IsTrue(IsVisible(harness, "Nothing connects"), "an EnumMember slot has no palette entry to offer");
+ Assert.IsFalse(IsVisible(harness, "Create Declarations"), "nothing connects, so no category should be listed");
+ }
+
+ ///
+ /// Tests that the menu offers a submenu for a category whose entries are grouped, which is where
+ /// the eighteen binary operators live.
+ ///
+ [TestMethod]
+ public void Menu_NestsAGroupedCategorysEntries()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ // A Body slot takes any statement, so the grouped assignment operators are on offer.
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Body", 1));
+
+ harness.Click("Create Statements");
+ harness.Step(2);
+
+ Assert.IsTrue(IsVisible(harness, "Create Statements Assignment"), "the grouped operators should be a submenu");
+ }
+
+ ///
+ /// Tests that dismissing the menu without picking anything leaves the document alone and forgets
+ /// the pin, rather than offering the menu again on the next frame.
+ ///
+ ///
+ /// ImNodes discards a dropped link itself, so there is nothing half-made to undo — cancelling has
+ /// to be a true no-op, and this is what says so.
+ ///
+ [TestMethod]
+ public void Menu_DismissedWithoutPicking_ChangesNothing()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1));
+ Assert.IsTrue(IsVisible(harness, "Create Declarations"), "the menu should be open to begin with");
+
+ int before = editor.Graph.Nodes.Count;
+
+ // Clicked away from the popup rather than dismissed with Escape: a click outside is what
+ // closes an ImGui popup, and it is also what a user who changed their mind actually does.
+ harness.Mouse.Click(1220, 60);
+ harness.Step(3);
+
+ Assert.IsFalse(IsVisible(harness, "Create Declarations"), "the menu should be gone once dismissed");
+ Assert.HasCount(before, editor.Graph.Nodes);
+ Assert.IsFalse(editor.History.CanUndo, "cancelling should record nothing");
+ Assert.HasCount(1, function.Parameters);
+ }
+
+ ///
+ /// Tests that opening a grouped submenu lists the entries inside it, which is where the operator
+ /// entries live.
+ ///
+ [TestMethod]
+ public void Menu_OpensAGroupedSubmenuToItsEntries()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Body", 1));
+
+ harness.Click("Create Statements");
+ harness.Step(2);
+
+ harness.Click("Create Statements Assignment");
+ harness.Step(2);
+
+ // Read from the catalogue rather than spelled here, so the assertion follows the operator set
+ // rather than pinning one operator's label.
+ string first = AstNodeCatalog.InGroup("Statements", "Assignment").First().Label;
+ Assert.IsTrue(IsVisible(harness, $"Create {first}"), $"the submenu should list '{first}'");
+ }
+
+ ///
+ /// Tests that a pin that is not in the graph is refused, so no menu is offered for it.
+ ///
+ [TestMethod]
+ public void RequestCreateFrom_UnknownPin_IsRefused()
+ {
+ AstGraphEditor editor = new(SampleFunction());
+
+ using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options);
+ harness.Step(2);
+
+ Assert.IsFalse(editor.RequestCreateFrom(-1));
+ harness.Step(2);
+
+ Assert.IsFalse(IsVisible(harness, "Create Declarations"));
+ Assert.IsFalse(IsVisible(harness, "Nothing connects"));
+ }
+
+ ///
+ /// Finds the pin standing for one place in a node's slot, the way the editor does.
+ ///
+ /// The graph under test.
+ /// The node whose slot to look at.
+ /// The slot's name.
+ /// The position within the slot.
+ /// The input pin's id.
+ private static int InputPin(AstGraph graph, AstNode parent, string slotName, int index)
+ {
+ ktsu.ImGui.NodeEditor.Node parentNode =
+ graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), parent));
+ AstSlot slot = AstSchema.SlotsOf(parent).Single(s => s.Name == slotName);
+ return parentNode.InputPins.Single(p => p.EffectiveDisplayName == AstGraph.PinLabel(slot, index)).Id;
+ }
+}
diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs
new file mode 100644
index 0000000..8ed786e
--- /dev/null
+++ b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs
@@ -0,0 +1,305 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Graph;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using ktsu.Coder.Ast;
+using ktsu.Coder.Graph;
+using ktsu.ImGui.NodeEditor;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests what a link dragged off a pin and dropped on empty canvas offers to create, and what
+/// picking one of those entries does to the document.
+///
+///
+/// These drive and
+/// directly rather than through ImGui. Which entries a drop offers and what accepting one attaches
+/// are rules, so they live where the rules are tested; the popup that lists them is part of the
+/// surface covers.
+///
+[TestClass]
+public class AstGraphEditorLinkDropTests
+{
+ ///
+ /// The whole menu a parameter's output pin should offer: only a function has a signature to put
+ /// one in.
+ ///
+ private static readonly string[] ExpectedForParameter = ["Function"];
+
+ private static FunctionDeclaration SampleFunction()
+ {
+ FunctionDeclaration function = new("total") { ReturnType = "int" };
+ function.Parameters.Add(new Parameter("a", "int"));
+ function.Body.Add(new ReturnStatement(
+ new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, Literal.Number(1))));
+ return function;
+ }
+
+ ///
+ /// Tests that dropping a drag begun at a slot offers every node the slot would take, and nothing
+ /// it would refuse.
+ ///
+ ///
+ /// A body takes any statement, which is everything in the palette except a parameter and an entry
+ /// point — a parameter belongs to a signature and a program starts running at an entry point, so
+ /// neither stands inside a body.
+ ///
+ [TestMethod]
+ public void CreatableFrom_InputPin_OffersEverythingTheSlotAccepts()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ AstNodeTemplate[] offered = [.. editor.CreatableFrom(InputPin(editor.Graph, function, "Body", 1))];
+
+ Assert.IsGreaterThan(0, offered.Length);
+
+ AstSlot body = AstSchema.SlotsOf(function).Single(slot => slot.Name == "Body");
+ foreach (AstNodeTemplate template in offered)
+ {
+ Assert.IsTrue(
+ AstSchema.Accepts(body, template.Create()),
+ $"'{template.Label}' was offered for Body but the slot would refuse it");
+ }
+
+ Assert.DoesNotContain("Parameter", Labels(offered));
+ Assert.DoesNotContain("Entry point", Labels(offered));
+ Assert.Contains("Return", Labels(offered));
+ }
+
+ ///
+ /// Tests that dropping a drag begun at a node's output offers only nodes with a slot that node
+ /// could sit in.
+ ///
+ ///
+ /// A parameter fits nothing in the palette but a function's signature, so that is the whole of the
+ /// menu — the filter is doing real work here rather than trimming an entry or two.
+ ///
+ [TestMethod]
+ public void CreatableFrom_OutputPin_OffersOnlyNodesWithARoomForIt()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+ Parameter parameter = function.Parameters[0];
+
+ AstNodeTemplate[] offered = [.. editor.CreatableFrom(OutputPin(editor.Graph, parameter))];
+
+ Assert.AreSequenceEqual(ExpectedForParameter, Labels(offered));
+ }
+
+ ///
+ /// Tests that a slot the palette cannot fill offers nothing, rather than offering entries the
+ /// connection would then refuse.
+ ///
+ ///
+ /// An enumeration's members take an and the palette has no entry for one,
+ /// so this is a genuinely empty answer rather than a lookup that failed.
+ ///
+ [TestMethod]
+ public void CreatableFrom_InputPin_OffersNothingWhenThePaletteCannotFillTheSlot()
+ {
+ EnumDeclaration enumeration = new("Colour");
+ AstGraphEditor editor = new(enumeration);
+
+ AstSlot members = AstSchema.SlotsOf(enumeration).Single();
+ AstNodeTemplate[] offered = [.. editor.CreatableFrom(InputPin(editor.Graph, enumeration, members.Name, 0))];
+
+ Assert.IsEmpty(offered);
+ }
+
+ ///
+ /// Tests that a pin that is not in the graph offers nothing.
+ ///
+ [TestMethod]
+ public void CreatableFrom_UnknownPin_OffersNothing()
+ {
+ AstGraphEditor editor = new(SampleFunction());
+
+ Assert.IsEmpty(editor.CreatableFrom(-1));
+ }
+
+ ///
+ /// Tests that picking an entry for a drag begun at a slot puts the new node in that slot.
+ ///
+ [TestMethod]
+ public void CreateFrom_InputPin_FillsTheSlot()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ bool created = editor.CreateFrom(
+ Template("Variable"),
+ InputPin(editor.Graph, function, "Body", 1),
+ new Vector2(40, 60));
+
+ Assert.IsTrue(created);
+ Assert.HasCount(2, function.Body);
+ Assert.IsInstanceOfType(function.Body[1]);
+ }
+
+ ///
+ /// Tests that picking an entry for a drag begun at a node's output moves that node into the new
+ /// one, rather than leaving both detached.
+ ///
+ ///
+ /// This is the direction the issue is really about: the user dragged out of a value looking for
+ /// somewhere to put it, so the node they pick has to arrive already holding it.
+ ///
+ [TestMethod]
+ public void CreateFrom_OutputPin_PutsTheDraggedNodeInTheNewOne()
+ {
+ ClassDeclaration holder = new("Holder");
+ AstGraphEditor editor = new(holder);
+
+ LiteralExpression literal = Literal.Number(7);
+ editor.Add(literal, new Vector2(10, 10));
+
+ bool created = editor.CreateFrom(
+ Template("Return"),
+ OutputPin(editor.Graph, literal),
+ new Vector2(80, 10));
+
+ Assert.IsTrue(created);
+
+ ReturnStatement statement = editor.Graph.Nodes.Values.OfType().Single();
+ Assert.AreSame(literal, statement.Expression);
+ }
+
+ ///
+ /// Tests that creating the node and connecting it are two steps in the history.
+ ///
+ ///
+ /// The same two the user would have taken by hand, so the first undo takes the connection back and
+ /// leaves the node they asked for on the canvas, rather than making the whole gesture all-or-nothing.
+ ///
+ [TestMethod]
+ public void CreateFrom_RecordsTheNodeAndTheConnectionSeparately()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ editor.CreateFrom(
+ Template("Variable"),
+ InputPin(editor.Graph, function, "Body", 1),
+ new Vector2(40, 60));
+
+ Assert.HasCount(2, function.Body);
+
+ editor.Undo();
+ Assert.HasCount(1, function.Body);
+ Assert.IsTrue(editor.History.CanUndo, "the node's own creation should still be on the stack");
+
+ editor.Undo();
+ Assert.HasCount(1, function.Body);
+ Assert.IsFalse(editor.History.CanUndo);
+ }
+
+ ///
+ /// Tests that a pin that is not in the graph is refused without touching the document or the
+ /// history.
+ ///
+ [TestMethod]
+ public void CreateFrom_UnknownPin_RecordsNothing()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+ int before = editor.Graph.Nodes.Count;
+
+ Assert.IsFalse(editor.CreateFrom(Template("Variable"), -1, new Vector2(40, 60)));
+ Assert.IsFalse(editor.History.CanUndo);
+ Assert.HasCount(before, editor.Graph.Nodes);
+ Assert.HasCount(1, function.Body);
+ }
+
+ ///
+ /// Tests that a template the slot would refuse is rejected without recording anything, even
+ /// though the pin itself is in the graph.
+ ///
+ ///
+ /// Not reachable from the menu, which only lists what connects, but
+ /// is public and a caller can hand it any pairing — so it refuses rather than attaching something
+ /// the slot would then reject.
+ ///
+ [TestMethod]
+ public void CreateFrom_InputPin_RefusesATemplateTheSlotWouldNotTake()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+ int before = editor.Graph.Nodes.Count;
+
+ // Parameters takes only a parameter, and a number literal is not one.
+ Assert.IsFalse(editor.CreateFrom(
+ Template("Number"),
+ InputPin(editor.Graph, function, "Parameters", 1),
+ new Vector2(40, 60)));
+
+ Assert.IsFalse(editor.History.CanUndo);
+ Assert.HasCount(before, editor.Graph.Nodes);
+ Assert.HasCount(1, function.Parameters);
+ }
+
+ ///
+ /// Tests that a template with nowhere to put the dragged node is refused without recording
+ /// anything.
+ ///
+ [TestMethod]
+ public void CreateFrom_OutputPin_RefusesATemplateWithNoSlotForTheDraggedNode()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+ Parameter parameter = function.Parameters[0];
+ int before = editor.Graph.Nodes.Count;
+
+ // A number literal has no slots at all, so it cannot take a parameter.
+ Assert.IsFalse(editor.CreateFrom(
+ Template("Number"),
+ OutputPin(editor.Graph, parameter),
+ new Vector2(40, 60)));
+
+ Assert.IsFalse(editor.History.CanUndo);
+ Assert.HasCount(before, editor.Graph.Nodes);
+ }
+
+ ///
+ /// Finds the palette entry with a given label.
+ ///
+ /// The label to look for.
+ /// The entry.
+ private static AstNodeTemplate Template(string label) =>
+ AstNodeCatalog.Templates.Single(template => template.Label == label);
+
+ ///
+ /// Lists the labels of a set of entries, for asserting on what a menu would show.
+ ///
+ /// The entries.
+ /// Their labels, in order.
+ private static string[] Labels(IEnumerable templates) =>
+ [.. templates.Select(template => template.Label)];
+
+ ///
+ /// Finds the pin standing for one place in a node's slot, the way the editor does.
+ ///
+ /// The graph under test.
+ /// The node whose slot to look at.
+ /// The slot's name.
+ /// The position within the slot.
+ /// The input pin's id.
+ private static int InputPin(AstGraph graph, AstNode parent, string slotName, int index)
+ {
+ Node parentNode = graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), parent));
+ AstSlot slot = AstSchema.SlotsOf(parent).Single(s => s.Name == slotName);
+ return parentNode.InputPins.Single(p => p.EffectiveDisplayName == AstGraph.PinLabel(slot, index)).Id;
+ }
+
+ ///
+ /// Finds a node's output pin.
+ ///
+ /// The graph under test.
+ /// The node to look at.
+ /// The output pin's id.
+ private static int OutputPin(AstGraph graph, AstNode node) =>
+ graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), node)).OutputPins[0].Id;
+}
diff --git a/Coder.Test/Graph/AstGraphEditorTests.cs b/Coder.Test/Graph/AstGraphEditorTests.cs
index f847d1f..9359b6a 100644
--- a/Coder.Test/Graph/AstGraphEditorTests.cs
+++ b/Coder.Test/Graph/AstGraphEditorTests.cs
@@ -440,4 +440,38 @@ public void Editor_UndoesAndRedoesAnEdit()
harness.Step();
Assert.AreEqual(before, editor.Graph.Nodes.Count);
}
+
+ ///
+ /// Tests that releasing a link drag over empty canvas runs the dropped-link path without faulting,
+ /// and creates nothing until an entry is picked.
+ ///
+ ///
+ /// Driven with real input, for the same reason the palette's test is: ImNodes.IsLinkDropped
+ /// is a native call reading state the renderer sets during a frame, so nothing short of rendering
+ /// one establishes it is being called correctly.
+ ///
+ /// Where a drag starts is layout-dependent, so this asserts what holds either way — the frame
+ /// renders and the document is untouched — rather than that the menu opened. What the menu offers,
+ /// and what picking an entry attaches, are rules, and they are covered headlessly in
+ /// .
+ ///
+ ///
+ [TestMethod]
+ public void Editor_HandlesALinkDroppedOnEmptyCanvas()
+ {
+ AstGraphEditor editor = new(SampleFunction());
+
+ using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options);
+ harness.Step(2);
+
+ int before = editor.Graph.Nodes.Count;
+
+ // Across the canvas and into a corner, so the release lands on empty space wherever the layout
+ // has put the nodes.
+ harness.Mouse.Drag(500, 300, 950, 560, steps: 8);
+ harness.Step(3);
+
+ Assert.HasCount(before, editor.Graph.Nodes, "a dropped link must not create anything on its own");
+ Assert.IsFalse(editor.History.CanUndo, "nothing was picked, so nothing should be on the undo stack");
+ }
}