Offer to create a node when a link is dropped on empty canvas - #75
Conversation
Dragging a link end off a pin and releasing it over nothing used to do nothing at all: ImNodes threw the link away and the user had to reach for the right-click palette, create a node, then drag the link a second time. Now the drop opens a menu of the nodes that would actually connect to the pin the drag began at, and picking one creates it at the drop position and wires it up. Which way the pin faces decides what is offered, because the new node takes the other end of the connection. A drag begun at a slot asks for something to fill it, so the entries are the ones that slot accepts. A drag begun at a node's output asks for somewhere to put that node, so the entries are the ones with a slot that accepts it -- and the node the user dragged moves into the new one rather than both being left detached. Both directions are filtered through the same AstSchema.Accepts the manual drag is checked against, so nothing is offered that the connection would then refuse, and the menu hides the categories and submenus that filtered empty. A slot the palette genuinely cannot fill -- an enumeration's members, which have no catalogue entry -- says so instead of listing entries that would not take it. Creating the node and connecting it go on the history as two steps, the same two the user would have taken by hand via the palette, so the first undo takes the connection back and leaves the node on the canvas. The rules live in AstGraphEditor.CreatableFrom/CreateFrom and AstGraph's two new pin lookups, so they are covered headlessly by eight tests in AstGraphEditorLinkDropTests. The draw path gets one harness test that performs a real drag through the CPU rasterizer, since ImNodes.IsLinkDropped is a native call that only a rendered frame exercises. Fixes #37 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ
SonarCloud failed the quality gate on this PR: 48.6% coverage on new code, against a required 80%. That was a fair call, not an artifact -- the repo sits at 92.6% overall, and the ~130 lines of menu-drawing code this PR added were exercised by nothing. The PR body admitted the popup body was untested and treated that as acceptable; it was not. The popup could not be opened by a test at all, because it only opened as a side effect of ImNodes reporting a dropped link, which needs a drag to land on a pin whose screen position depends on the force-directed layout. That was a design problem, not a testing one: the existing DrawPalette is covered precisely because a right-click opens it unconditionally. So offering the menu is now an operation, RequestCreateFrom(pinId), and ApplyLinkChanges calls it when ImNodes reports the drop. A host binding the same affordance to a key wants this entry point too. It only records the request; the popup is opened on the next frame drawn, because ImGui.OpenPopup means nothing outside a frame. The menu's categories, groups and entries are now named for the probes the way the inspector's rows already are, so a test picks an entry by the label the user reads rather than by a coordinate. Seven harness tests in AstGraphEditorLinkDropMenuTests drive the popup for real through the CPU rasterizer: which categories it lists, clicking through to an entry and getting the node created and connected, the grouped operator submenu, the empty-slot message, dismissal recording nothing, and an unknown pin being refused. Two headless tests cover the refusal branches in CreateFrom that nothing reached before. New-code coverage measured locally: 48.6% -> 95.2% (140/147 instrumented lines). Full suite 885/885 in Release, 0 warnings. Seven lines remain uncovered, all deliberately: - the IsLinkDropped branch itself, which still needs a drag to land on a pin - an empty group inside a non-empty category, which no slot in the catalogue currently produces - NothingConnects' output-pin branch, which is unreachable today because AstSlotKind.Statement accepts everything except a Parameter and an EntryPoint, so every node has somewhere in the palette to go. It stays as a guard rather than being deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ
Fixed the quality gate — it was a fair call, and the PR body was wrong to shrug at itSonarCloud failed on The real problem was a design one, not a testing oneThe popup could not be opened by any test, because it only opened as a side effect of So offering the menu is now an operation: public bool RequestCreateFrom(int pinId)
I also named the menu's categories, groups and entries for the probes, the way the inspector's rows already are, so a test picks an entry by the label the user reads rather than by a coordinate. That was a convention I'd missed. Coverage
Seven new harness tests drive the popup for real through the CPU rasterizer: which categories it lists, clicking through to an entry and getting the node created and connected, the grouped operator submenu, the empty-slot message, dismissal recording nothing, and an unknown pin being refused. Two headless tests cover the refusal branches in Seven lines still uncovered, deliberately
Generated by Claude Code |
SonarCloud passed the gate on 5157af2 (97.8% coverage on new code) but reported ten new issues, one of them critical. S3776, critical: DrawLinkDropMenu had a cognitive complexity of 17 against the allowed 15. Fair -- it had grown a category loop and a group loop inside the popup's own open-and-guard sequence. Split the two loops out as DrawLinkDropCategory and DrawLinkDropGroup, which also lets each one return early instead of continuing, so the nesting that drove the score is gone. The other nine are MSTest analyser suggestions at info level on assertions this PR added, all mechanical and all plainly right, so they ride along rather than waiting for a push of their own: - Assert.AreEqual(n, x.Count) -> Assert.HasCount(n, x) - CollectionAssert.Contains/DoesNotContain -> Assert.Contains/DoesNotContain - CollectionAssert.AreEquivalent -> Assert.AreSequenceEqual No behaviour change. Full suite 885/885 in Release, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ
|



Fixes #37
Dragging a link end off a pin and releasing it over nothing used to do nothing at all: ImNodes threw the link away, and the user had to reach for the right-click palette, create a node, then drag the link a second time. Now the drop opens a menu of the nodes that would actually connect to the pin the drag began at, and picking one creates it at the drop position and wires it up.
Which way the pin faces decides what is offered
The new node takes the other end of the connection, so the two directions are not the same menu:
In the output-pin case the node the user dragged moves into the new one, rather than both being left detached — that is the direction the issue is really about.
Both are filtered through the same
AstSchema.Acceptsthe manual drag is checked against, so nothing is offered that the connection would then refuse. Per the triage's suggestion, that check is reused rather than re-implemented.The two UX questions the triage raised
"No compatible node type exists" — the menu says so, as disabled text naming what was dragged, instead of opening empty. Categories and submenus that filtered empty are hidden, so the menu is a shortlist rather than the whole catalogue with most of it refusing.
This is reachable when the drag began at a slot: an enumeration's members take an
EnumMemberand the palette has no entry for one, which is what the test for it uses. It is not currently reachable from an output pin, and the reason is worth recording —AstSlotKind.Statementaccepts everything except aParameterand anEntryPoint, so every node in the catalogue has somewhere to go. (I found this by writing a test that assumed anEnumMemberwould be refused everywhere; it isn't, because a body slot takes one. That may be worth its own look, but it is pre-existing and not this PR's to change.) That branch stays as a guard rather than being deleted.Cancelling out of the menu — nothing is left dangling. ImNodes discards a dropped link itself, so there is no half-made connection to undo; dismissing the popup only forgets the remembered pin. There is a test for exactly this.
History
Creating the node and connecting it go on the stack as two steps, matching the documented intent on
Add("creating one and connecting it are two steps in the history rather than one — that matches what the user did"). The first undo takes the connection back and leaves the node the user asked for on the canvas, which is a state they can reach by hand too.Where the code sits
The class docs say this type "only reads what the user did and asks the graph to do it, so the part that cannot be tested without a GPU holds no rules of its own". The rules therefore went into testable methods rather than the draw call:
AstGraph.OwnerOfOutputPin/AstGraph.LocationOfInputPin— two pin lookups, so a caller holding one pin id can tell which end of a connection it is.LocationOfInputPinreturns anAstLocationrather than the private pin record, because a location names nodes and outlives the rebuild that reassigns pin ids.AstGraphEditor.CreatableFrom(pinId)— the filtered entries. Sits besideConversionsFor, which is the same shape of rule.AstGraphEditor.CreateFrom(template, pinId, position)— creates and connects. Refuses without recording anything if the pin is not in the graph.AstGraphEditor.RequestCreateFrom(pinId)— offers the menu.ApplyLinkChangescalls it when ImNodes reports a drop, but it is a real entry point in its own right: a host binding the same affordance to a key wants it, and it makes the menu reachable without a drag landing on a pin by luck. It records only the request, sinceImGui.OpenPopupmeans nothing outside a frame.Verification
The rule tests were checked against two deliberately broken implementations, rather than only against the working one:
Acceptsassertion, the parameter menu growing from 1 entry to 49, and the empty-menu case.Ten headless tests in
AstGraphEditorLinkDropTestscover the rules. Seven harness tests inAstGraphEditorLinkDropMenuTestsdrive the popup itself through the CPU rasterizer — which categories it lists, clicking through to an entry and getting the node created and connected, the grouped operator submenu, the empty-slot message, dismissal recording nothing, and an unknown pin being refused. One further harness test performs a real drag, sinceImNodes.IsLinkDroppedis a native call that only a rendered frame exercises.Seven lines remain uncovered, deliberately: the
IsLinkDroppedbranch itself (still needs a drag to land on a pin), an empty group inside a non-empty category (no slot in the catalogue produces that combination), and the output-pin branch of the "nothing connects" message (unreachable, per above).Note on the issue's triage comments
The triage on #37 (and on #35/#36) names
NodeEditorRenderer,NodeEditorInputHandler,NodeEditorEngineandAttributeBasedNodeFactoryas the places to change. OnlyNodeEditorEngine/NodeEditorRendererexist, and they come from thektsu.ImGui.NodeEditorpackage rather than this repo; there is noAttributeBasedNodeFactoryorNodeEditorInputHandleranywhere, and the node types come from the curatedAstNodeCataloginstead. The plan held up, but anyone picking up #35 or #36 should not trust those class names.🤖 Generated with Claude Code
https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ