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
21 changes: 21 additions & 0 deletions Coder.Graph/AstGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ public AstGraph(AstNode root)
? new AstLocation(pin.Parent, pin.Slot, pin.Index)
: null;

/// <summary>
/// Finds the child currently sitting in an input pin.
/// </summary>
/// <param name="inputPinId">The pin's id.</param>
/// <returns>The child filling the pin, or null when the pin is empty or is not an input pin of this graph.</returns>
/// <remarks>
/// A variadic slot draws a free pin past its last child, so a pin being in the graph does not mean
/// anything is in it. That is what separates this from <see cref="LocationOfInputPin"/>: the
/// location says where the pin points, this says whether anything is there.
/// </remarks>
public AstNode? ChildInPin(int inputPinId)
{
if (LocationOfInputPin(inputPinId) is not AstLocation place || place.Parent is null || place.Slot is null)
{
return null;
}

IReadOnlyList<AstNode> children = AstSchema.ChildrenOf(place.Parent, place.Slot);
return place.Index < children.Count ? children[place.Index] : null;
}

/// <summary>
/// Rebuilds the engine graph from the current AST, preserving on-screen positions.
/// </summary>
Expand Down
221 changes: 203 additions & 18 deletions Coder.Graph/AstGraphEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
/// </summary>
private const string NodeMenuPopup = "ast-graph-node-menu";

/// <summary>
/// The popup offering a pin's own actions, opened by right-clicking it.
/// </summary>
private const string PinMenuPopup = "ast-graph-pin-menu";

private string statusMessage = string.Empty;

private string fieldBuffer = string.Empty;
Expand Down Expand Up @@ -119,6 +124,22 @@
/// </remarks>
private bool nodeMenuOpening;

/// <summary>
/// The pin whose own menu is open, while it is. Null when none is.
/// </summary>
/// <remarks>
/// Held across frames for the reason <see cref="linkDropPin"/> is: the right-click and the choice
/// happen in different ones. The pin is held by identifier rather than as the place it points at,
/// because every edit offered here closes the menu, so there is no frame in which the menu is open
/// and the rebuild that reassigns those identifiers has happened.
/// </remarks>
private int? pinMenuPin;

/// <summary>
/// Whether the menu for <see cref="pinMenuPin"/> still has to be opened.
/// </summary>
private bool pinMenuOpening;

/// <summary>
/// Gets the graph being edited.
/// </summary>
Expand Down Expand Up @@ -247,6 +268,7 @@
ApplyDeletions();
DrawPalette();
DrawNodeMenu();
DrawPinMenu();
DrawLinkDropMenu();

if (ShowDebugOverlays)
Expand Down Expand Up @@ -869,9 +891,30 @@
/// </summary>
/// <param name="linkId">The link to cut.</param>
/// <returns>True if the link was found and cut.</returns>
public bool Disconnect(int linkId)
public bool Disconnect(int linkId) => Detach(Graph.ChildOfLink(linkId));

/// <summary>
/// Cuts whatever fills a pin loose from it, as one undoable edit.
/// </summary>
/// <param name="pinId">The pin to empty.</param>
/// <returns>True if the pin held a child, which was cut loose.</returns>
/// <remarks>
/// The same edit as cutting the link, reached from the pin rather than from the line drawn to it: a
/// link is a few pixels wide, and the pin is what the user was already pointing at.
/// </remarks>
public bool DisconnectPin(int pinId) => Detach(Graph.ChildInPin(pinId));

/// <summary>
/// Cuts a child loose from its parent, as one undoable edit.
/// </summary>
/// <param name="child">The node to detach, or null when there was nothing to detach.</param>
/// <returns>True if there was a node, and it was detached.</returns>
/// <remarks>
/// The node stays in the graph with no parent rather than leaving it: the user asked for it not to
/// be connected there, which is not the same as asking for it to be gone.
/// </remarks>
private bool Detach(AstNode? child)
{
AstNode? child = Graph.ChildOfLink(linkId);
if (child is null)
{
return false;
Expand Down Expand Up @@ -1042,7 +1085,7 @@
{
// Undone in the order the replacement was made: the original goes back into its place,
// then each child that moved goes back into the slot it came from.
Graph.Replace(replacement, existing);

Check warning on line 1088 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Parameters to 'Replace' have the same names but not the same order as the method arguments.

Check warning on line 1088 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Parameters to 'Replace' have the same names but not the same order as the method arguments.

Check warning on line 1088 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Parameters to 'Replace' have the same names but not the same order as the method arguments.

Check warning on line 1088 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Parameters to 'Replace' have the same names but not the same order as the method arguments.
Graph.MoveTo(existing, from);
foreach ((AstNode child, AstLocation origin) in moved)
{
Expand Down Expand Up @@ -1228,6 +1271,118 @@
ImGui.EndPopup();
}

/// <summary>
/// Offers a pin's own menu, the way right-clicking the pin does.
/// </summary>
/// <param name="pinId">The pin to offer the menu for.</param>
/// <returns>True if the pin is one this graph offers a menu for, so the menu will open.</returns>
/// <remarks>
/// Separate from the gesture that usually triggers it, for the reason <see cref="RequestCreateFrom"/>
/// is: a host binding the menu to a key wants the same menu, and it can be driven in a test without
/// a pin happening to be under the pointer.
/// <para>
/// Only a slot's pin has a menu. A node's output pin stands for the node itself rather than for a
/// place in the document, so its actions are the node's, and a right-click on one falls through to
/// the node's own menu.
/// </para>
/// </remarks>
public bool RequestPinMenu(int pinId)
{
if (Graph.LocationOfInputPin(pinId) is null)
{
statusMessage = Graph.OwnerOfOutputPin(pinId) is null
? "That pin is not in this graph."
: "An output pin stands for the node itself, so it has no menu of its own.";
return false;
}

pinMenuPin = pinId;
pinMenuOpening = true;
return true;
}

/// <summary>
/// Draws a pin's own menu, and makes whichever edit the user picks.
/// </summary>
/// <remarks>
/// What a pin offers is what can be done to the place it stands for: fill it, cut loose what fills
/// it, or remove that outright. The last two are offered only when something is there, since a
/// variadic slot draws a free pin past its last child and that one has nothing to act on.
/// <para>
/// Renaming is not offered, because a pin has no name of its own to change: it is labelled from the
/// slot it fills, which is part of the node's shape rather than of the document. What the pin holds
/// is renamed by editing that node's own Name in the inspector, which is where the rest of a node's
/// values are edited.
/// </para>
/// </remarks>
private void DrawPinMenu()
{
if (pinMenuPin is not int pinId)
{
return;
}

if (pinMenuOpening)
{
ImGui.OpenPopup(PinMenuPopup);
pinMenuOpening = false;
}

if (!ImGui.BeginPopup(PinMenuPopup))
{
// Remembered exactly as long as the popup is open: once it has gone the user either picked
// something or dismissed it, and either way the pin is spent.
pinMenuPin = null;
return;
}

AstNode? child = Graph.ChildInPin(pinId);

bool create = Picked("Create node here", "Pin create");
bool disconnect = child is not null && Picked("Disconnect", "Pin disconnect");
bool remove = child is not null && Picked("Delete", "Pin delete");

if (create)
{
// Offered rather than made here: what would fill the pin is a choice, and it is the one the
// menu a dropped link opens already asks. This reaches it without the drag.
RequestCreateFrom(pinId);
}
else if (disconnect)
{
DisconnectPin(pinId);
}
else if (remove)
{
RemoveFromPin(pinId);
}

if (create || disconnect || remove)
{
pinMenuPin = null;
ImGui.CloseCurrentPopup();
}

ImGui.EndPopup();
}

/// <summary>
/// Draws one entry of a pin's menu, under a name a test can ask for.
/// </summary>
/// <param name="label">The entry as the user reads it.</param>
/// <param name="probe">The name the entry is recorded under.</param>
/// <returns>True if the user picked it.</returns>
/// <remarks>
/// Marked under a name of its own rather than the label it reads as, because a word as ordinary as
/// "Delete" will be drawn elsewhere too and a test asking for one should not find the other.
/// </remarks>
private static bool Picked(string label, string probe)
{
bool picked = ImGui.MenuItem(label);
ImGuiProbes.MarkItem(probe);
return picked;
}

/// <summary>
/// Creates a node from a template and connects it to the pin a link was dragged off.
/// </summary>
Expand Down Expand Up @@ -1412,9 +1567,26 @@
/// The removal is checked before it is recorded, so pressing delete over the root — which is
/// never removed — leaves the history alone rather than adding a step that does nothing.
/// </remarks>
public bool Remove(int nodeId)
public bool Remove(int nodeId) => RemoveSubtree(Graph.AstNodeFor(nodeId));

/// <summary>
/// Removes whatever fills a pin, and everything under it, as one undoable edit.
/// </summary>
/// <param name="pinId">The pin to empty.</param>
/// <returns>True if the pin held a child, which was removed.</returns>
/// <remarks>
/// Told apart from <see cref="DisconnectPin"/>, which leaves the child in the graph: this is the
/// edit pressing delete over the child makes, reached from the pin holding it.
/// </remarks>
public bool RemoveFromPin(int pinId) => RemoveSubtree(Graph.ChildInPin(pinId));

/// <summary>
/// Removes a node and everything under it, as one undoable edit.
/// </summary>
/// <param name="node">The node to remove, or null when there was nothing to remove.</param>
/// <returns>True if there was a node that could be removed, and it was.</returns>
private bool RemoveSubtree(AstNode? node)
{
AstNode? node = Graph.AstNodeFor(nodeId);
if (node is null || ReferenceEquals(node, Graph.Root))
{
return false;
Expand Down Expand Up @@ -1512,6 +1684,23 @@
}
}

/// <summary>
/// Offers the menu belonging to whatever the pointer is over, if it is over anything.
/// </summary>
/// <returns>True if a pin's or a node's own menu was offered, so the palette is not.</returns>
/// <remarks>
/// A pin sits inside a node, so a click that is over both is about the pin: the narrower target is
/// the one it took aim to hit. A node's output pin has no menu of its own — it stands for the node
/// rather than for a place in the document — so a click on one falls through to the node's, which
/// is whose actions they are.
/// </remarks>
private bool OfferOwnMenu()
{
int hovered = 0;
return (ImNodes.IsPinHovered(ref hovered) && RequestPinMenu(hovered))
|| (ImNodes.IsNodeHovered(ref hovered) && RequestNodeMenu(hovered));
}

/// <summary>
/// Draws the right-click palette and creates whatever the user picks.
/// </summary>
Expand All @@ -1521,20 +1710,16 @@
/// </remarks>
private void DrawPalette()
{
if (ImGui.IsMouseClicked(ImGuiMouseButton.Right) && ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows))
{
// A right-click over a node is about that node, so the palette answers only one over empty
// canvas. Both gestures are the same click, and which menu it means is decided in one place
// rather than by two handlers each opening a popup.
int hovered = 0;
if (ImNodes.IsNodeHovered(ref hovered))
{
RequestNodeMenu(hovered);
}
else
{
ImGui.OpenPopup("ast-graph-palette");
}
// A right-click over a pin is about that pin and one over a node is about that node, so the
// palette answers only a click that lands on neither. All three are the same click, and which
// menu it means is decided in one place rather than by three handlers racing to open a popup.
// OfferOwnMenu is asked last, and only where the click reaches this canvas at all, so a menu is
// never offered for a click somewhere else in the window.
if (ImGui.IsMouseClicked(ImGuiMouseButton.Right)
&& ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows)
&& !OfferOwnMenu())
{
ImGui.OpenPopup("ast-graph-palette");
}

if (!ImGui.BeginPopup("ast-graph-palette"))
Expand All @@ -1555,7 +1740,7 @@

// A category's operator entries are one submenu deep, so eighteen binary operators do not
// bury the four literals.
foreach (string group in AstNodeCatalog.GroupsIn(category))

Check warning on line 1743 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 1743 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 1743 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 1743 in Coder.Graph/AstGraphEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (ImGui.BeginMenu(group))
{
Expand Down
Loading
Loading