diff --git a/CLAUDE.md b/CLAUDE.md index a631b3d..9b3e3ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.) - **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`. - **NodeGraph** (`ktsu.NodeGraph`) - UI-agnostic attribute-based node graph metadata: `[Node]`, `[InputPin]`, `[OutputPin]`, `[NodeExecute]`, `[NodeBehavior]`, pin type utilities -- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in +- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in. Hovering is answered by the renderer: `HighlightLinksOnNodeHover` (on) colours the links meeting the hovered node, `HighlightDownstreamOnNodeHover` (off) also colours everything that node's value reaches, and `DrawHoveredLinkOnTop` (on) redraws the hovered link over the nodes ImNodes drew on top of it. See [Hover highlighting](#hover-highlighting) below. How many links a pin accepts is the pin's own business: `Pin.AllowsMultipleConnections` defaults to many for an output and one for an input, `[InputPin(AllowMultipleConnections = true)]` / `[OutputPin(AllowMultipleConnections = false)]` override it through the factory, and `NodeEditorEngine.SetPinAllowsMultipleConnections` sets it directly. `GetOutgoingLinks`, `GetIncomingLinks`, `GetDownstream` and `GetUpstream` walk the graph - **ImGui.Markdown** (`ktsu.ImGui.Markdown`) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiMarkdown.Render(string, MarkdownConfig?)` parses with an internal source-keyed cache; `MarkdownDocument` parses once for hot render paths. `MarkdownConfig` exposes `FontResolver`, `OnLinkClicked`, `ImageResolver`, `HeadingScales`, `WrapWidth`, `ListIndentPixels`, `ParagraphSpacingPixels`, and `LinkColor`. Heading sizes derive from the live font size, so DPI and `ImGuiApp.GlobalScale` are respected automatically. Bold/italic use real glyphs when the host app registers named font variants via `FontResolver`, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go to `MarkdownConfig.CodeBlockRenderer` (`Action?` — the fence's info string and the block text) when one is supplied, which takes over drawing *and* reserving the block's layout space; `ImGui.SyntaxHighlighting` plugs into it, and neither library references the other. v1 has no built-in code-block syntax highlighting, no async remote image download, and renders HTML as escaped text. - **SyntaxHighlighting** (`ktsu.SyntaxHighlighting`) - Renderer-agnostic tokenizing: no ImGui, no graphics API, no third-party parser, so it can move to its own repository unchanged. `SyntaxHighlighter.Highlight(code, language, tabWidth)` returns the classified `HighlightedLine`/`HighlightedToken` runs; `SyntaxHighlighter.HighlightCached` goes through a bounded cache keyed by source, language and tab width; `HighlightedCode` tokenizes once for hot render paths. Languages are data (`LanguageDefinition`: line/block comment, string, keyword, type, constant, operator, identifier and embedded-language rules) held in `LanguageRegistry`, which resolves names and aliases case-insensitively and falls back to plain text for unknown names rather than throwing. Fifteen built-ins in `BuiltInLanguages`: text, csharp, c, cpp, javascript, typescript, python, json, yaml, xml, html, css, sql, shell, lua. Two tokenizers back them — the general `CodeTokenizer`, and `MarkupTokenizer` for definitions with `IsMarkup` (XML/HTML), which classify structurally rather than by keyword. `SyntaxTheme` holds one `ktsu.Semantics.Color.Color` per `TokenKind`, with `Dark`/`Light` built in and `Background`/`Plain`/`LineNumber` left unset for the host to fill. Comments and strings are searched for an embedded language; see [Embedded languages](#embedded-languages) below. Highlighting is lexical. - **ImGui.SyntaxHighlighting** (`ktsu.ImGui.SyntaxHighlighting`) - The Dear ImGui drawing layer over `ktsu.SyntaxHighlighting`, layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiSyntaxHighlighting.Render(code, language, SyntaxHighlightConfig?)` tokenizes through the shared cache and draws; `Render(HighlightedCode, config)` draws pre-tokenized code, and `HighlightedCodeExtensions` re-adds `code.Render(config)` as an extension since the tokenized type itself knows nothing about ImGui. `Highlight` forwards to `SyntaxHighlighter.Highlight`. Leaving `SyntaxHighlightConfig.Theme` null picks between `SyntaxTheme.Dark`/`Light` per frame from the window background's luminance, and unset `Background`/`Plain`/`LineNumber` come from `FrameBg`/`Text`/`TextDisabled`. Code is never wrapped, and there is no scrolling, selection or editing. `ImGui.Markdown`'s `CodeBlockRenderer` plugs into this, and neither library references the other. @@ -53,6 +53,10 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De in memory so the decoders can be driven over their whole feature matrix without binary fixtures; the JPEG cases, which need a real encoder, are small base64 constants in `JpegDecoderTests`. - `tests/NodeGraph.Tests/` - Node graph attribute and type utility tests +- `tests/ImGui.NodeEditor.Tests/` - Engine, factory and rendering tests for the node editor. The + engine and factory ones need no context; `NodeRenderingTests`, `ZoomTests` and + `HoverHighlightTests` drive real frames through `ImGuiAppHarness`, since zoom and hover are made + of what the renderer writes into ImNodes and reads back out, and neither exists without drawing - `tests/.UITests/` - One headless UI test project per example, driving the demo's real `BuildConfig()` through `ImGuiAppHarness`: `ImGuiAppDemo.UITests`, `ImGuiWidgetsDemo.UITests`, `ImGuiStylerDemo.UITests`, `ImGuiPopupsDemo.UITests`, `ImGuiMarkdownDemo.UITests`, @@ -405,6 +409,28 @@ The node graph system follows a clean separation of concerns: is why `FitToView` moves the nodes. Zoom is applied on the way into ImNodes and undone on the way back out, so nothing zoomed ever reaches the engine. +### Hover highlighting + +ImNodes only answers what the pointer is over once the editor has ended, so `NodeEditorRenderer` +asks after `EndNodeEditor` and highlights on the next frame. A frame's lag on a pointer that has to +rest on a node to mean anything is not one anybody sees, and it is what lets the colours be pushed +per node and per link: ImNodes copies a link's three colours, and a node's outline colour and border +thickness, out of the style as each is submitted, so `PushColorStyle` around one submission colours +that one thing. + +Two consequences worth knowing: + +- **A hovered link is drawn twice.** ImNodes puts every link in a channel below the nodes and offers + no way to lift one out, so `DrawHoveredLinkOnTop` draws the same cubic bezier again — between the + same two pin positions, with control points a quarter of the straight-line distance to either + side, which is the curve ImNodes itself draws — on the **foreground draw list**, clipped to the + editor. It has to be the foreground list: the editor is a child window, and a child window's + drawing is composited over its parent's, so an overlay on the window draw list ends up underneath + the nodes. `RenderDebugOverlays` draws there for the same reason. +- **ImNodes resolves a hovered node before a hovered link.** The part of a link that passes behind a + node cannot be pointed at, only the part in the open, which is why the overlay is about following + a link you have already caught hold of rather than about grabbing one. + ### Key Technical Details - **PID frame limiter** with auto-tuning (Coarse/Fine/Precision phases) diff --git a/ImGui.NodeEditor/AttributeBasedNodeFactory.cs b/ImGui.NodeEditor/AttributeBasedNodeFactory.cs index 9abffeb..db1b9df 100644 --- a/ImGui.NodeEditor/AttributeBasedNodeFactory.cs +++ b/ImGui.NodeEditor/AttributeBasedNodeFactory.cs @@ -125,11 +125,14 @@ public Node CreateNode(Type nodeType, Vector2 position) .OrderBy(p => p.Order) .Select(p => p.DisplayName)]; - return engine.CreateNode( + Node node = engine.CreateNode( position, definition.DisplayName, inputPinNames, outputPinNames); + + ApplyConnectionCapacities(definition, node); + return node; } /// @@ -154,11 +157,39 @@ public Node CreateMethodNode(MethodInfo method, Vector2 position) .OrderBy(p => p.Order) .Select(p => p.DisplayName)]; - return engine.CreateNode( + Node node = engine.CreateNode( position, definition.DisplayName, inputPinNames, outputPinNames); + + ApplyConnectionCapacities(definition, node); + return node; + } + + /// + /// Carry each declared pin's connection capacity onto the pin the engine just created. + /// + /// The definition the node was created from. + /// The created node. + /// + /// The engine creates pins from names alone, so without this an + /// [OutputPin(AllowMultipleConnections = false)] would be declared and then ignored. The + /// pins are matched by the order they were created in, which is the order the names were passed + /// in. + /// + private void ApplyConnectionCapacities(NodeDefinition definition, Node node) + { + ApplyConnectionCapacities([.. definition.InputPins.OrderBy(p => p.Order)], node.InputPins); + ApplyConnectionCapacities([.. definition.OutputPins.OrderBy(p => p.Order)], node.OutputPins); + } + + private void ApplyConnectionCapacities(List definitions, List pins) + { + for (int i = 0; i < definitions.Count && i < pins.Count; i++) + { + engine.SetPinAllowsMultipleConnections(pins[i].Id, definitions[i].AllowMultipleConnections); + } } /// diff --git a/ImGui.NodeEditor/DomainModels.cs b/ImGui.NodeEditor/DomainModels.cs index 348bb44..0279509 100644 --- a/ImGui.NodeEditor/DomainModels.cs +++ b/ImGui.NodeEditor/DomainModels.cs @@ -32,17 +32,42 @@ int InputPinId /// /// Represents a pin on a node /// +/// The pin's identifier, unique across every pin in the graph. +/// Whether the pin takes a connection in or sends one out. +/// The pin's name. +/// What to show instead of , when they differ. +/// +/// How many links may meet this pin, or null to take the default for its direction. See +/// . +/// public record Pin( int Id, PinDirection Direction, string Name, - string? DisplayName = null + string? DisplayName = null, + bool? AllowMultipleConnections = null ) { /// /// Gets the name to display in the UI, preferring DisplayName over Name /// public string EffectiveDisplayName => DisplayName ?? Name; + + /// + /// Whether more than one link may meet this pin. + /// + /// + /// An output pin fans out by default: one value can feed as many consumers as want it, and + /// refusing the second link would mean inserting a node whose only job is to duplicate the + /// first. An input pin takes one link by default, because a pin fed from two places has no + /// answer to which value it holds. + /// + /// Either default is overridden by giving a value, which + /// is how a declared [InputPin(AllowMultipleConnections = true)] or + /// [OutputPin(AllowMultipleConnections = false)] reaches the graph. + /// + /// + public bool AllowsMultipleConnections => AllowMultipleConnections ?? (Direction == PinDirection.Output); }; /// diff --git a/ImGui.NodeEditor/NodeEditorEngine.cs b/ImGui.NodeEditor/NodeEditorEngine.cs index e7d2928..6e2d4c5 100644 --- a/ImGui.NodeEditor/NodeEditorEngine.cs +++ b/ImGui.NodeEditor/NodeEditorEngine.cs @@ -203,8 +203,19 @@ public LinkCreationResult TryCreateLink(int fromPinId, int toPinId) Pin outputPin = fromPin.Direction == PinDirection.Output ? fromPin : toPin; Pin inputPin = fromPin.Direction == PinDirection.Input ? fromPin : toPin; - // Only one input connection per pin. - if (links.Any(l => l.InputPinId == inputPin.Id)) + if (links.Any(l => l.OutputPinId == outputPin.Id && l.InputPinId == inputPin.Id)) + { + return new LinkCreationResult(false, "Those pins are already linked"); + } + + // How many links a pin accepts is the pin's own business: an output fans out to every + // consumer that wants its value, an input takes one, and either can say otherwise. + if (!outputPin.AllowsMultipleConnections && links.Any(l => l.OutputPinId == outputPin.Id)) + { + return new LinkCreationResult(false, "Output pin already connected"); + } + + if (!inputPin.AllowsMultipleConnections && links.Any(l => l.InputPinId == inputPin.Id)) { return new LinkCreationResult(false, "Input pin already connected"); } @@ -295,6 +306,134 @@ public IEnumerable GetNodeLinks(int nodeId) node.OutputPins.Any(p => p.Id == l.OutputPinId)); } + /// + /// Change how many links a pin accepts, overriding the default for its direction. + /// + /// The pin to change. + /// True to let the pin take any number of links, false for one. + /// True if the pin was found. + /// + /// Links already made are left alone: narrowing a pin that is connected twice does not + /// disconnect either link, it only refuses the next one. + /// + public bool SetPinAllowsMultipleConnections(int pinId, bool allowMultiple) + { + // A pin id is unique across the graph, so there is one node to find rather than a sequence + // to walk looking for it. + Node? owner = nodes.Find(n => + n.InputPins.Any(p => p.Id == pinId) || + n.OutputPins.Any(p => p.Id == pinId)); + + return owner is not null && + (TrySetPinCapacity(owner.InputPins, pinId, allowMultiple) || + TrySetPinCapacity(owner.OutputPins, pinId, allowMultiple)); + } + + private static bool TrySetPinCapacity(List pins, int pinId, bool allowMultiple) + { + int index = pins.FindIndex(p => p.Id == pinId); + if (index < 0) + { + return false; + } + + pins[index] = pins[index] with { AllowMultipleConnections = allowMultiple }; + return true; + } + + /// Get the links leaving a node through its output pins. + /// The node. + /// Its outgoing links, or nothing if there is no such node. + public IEnumerable GetOutgoingLinks(int nodeId) + { + Node? node = nodes.FirstOrDefault(n => n.Id == nodeId); + return node is null + ? [] + : links.Where(l => node.OutputPins.Any(p => p.Id == l.OutputPinId)); + } + + /// Get the links arriving at a node through its input pins. + /// The node. + /// Its incoming links, or nothing if there is no such node. + public IEnumerable GetIncomingLinks(int nodeId) + { + Node? node = nodes.FirstOrDefault(n => n.Id == nodeId); + return node is null + ? [] + : links.Where(l => node.InputPins.Any(p => p.Id == l.InputPinId)); + } + + /// + /// Everything a node's value reaches: the nodes found by following links forward from it, and + /// the links walked to get there. + /// + /// The node to start from. + /// The reach, which is empty when the node has no outgoing links or does not exist. + /// + /// The starting node is not part of its own reach unless a cycle leads back to it, which is + /// reported rather than hidden: a node that feeds itself round a loop really is downstream of + /// itself, and the walk visits each node once so a cycle terminates. + /// + public GraphReach GetDownstream(int nodeId) => Walk(nodeId, forward: true); + + /// + /// Everything a node's value comes from: the nodes found by following links backward from it, + /// and the links walked to get there. + /// + /// The node to start from. + /// The reach, which is empty when the node has no incoming links or does not exist. + /// + /// The mirror of in every respect, cycles included. + /// + public GraphReach GetUpstream(int nodeId) => Walk(nodeId, forward: false); + + /// + /// Walk the graph from one node, following links in one direction. + /// + /// The node to start from. + /// True to follow links away from the node, false to follow them back. + /// The nodes reached and the links walked to reach them. + private GraphReach Walk(int nodeId, bool forward) + { + // Off the map of pins to nodes, so following a link is a lookup rather than a search + // through every node's pins. It is a cache of what the nodes already say, so building it + // here costs a pass and changes nothing. + RebuildPinIdToNodeIdMap(); + + HashSet reachedNodes = []; + HashSet walkedLinks = []; + HashSet visited = [nodeId]; + Queue pending = new(); + pending.Enqueue(nodeId); + + while (pending.Count > 0) + { + int current = pending.Dequeue(); + + foreach (Link link in forward ? GetOutgoingLinks(current) : GetIncomingLinks(current)) + { + walkedLinks.Add(link.Id); + + // A link is walked towards the far end of it, which is the input pin going forward + // and the output pin going back. + int farPin = forward ? link.InputPinId : link.OutputPinId; + if (!pinIdToNodeId.TryGetValue(farPin, out int reachedId)) + { + continue; + } + + reachedNodes.Add(reachedId); + + if (visited.Add(reachedId)) + { + pending.Enqueue(reachedId); + } + } + } + + return new GraphReach(reachedNodes, walkedLinks); + } + /// Calculate the distance between two connected nodes. public float? GetLinkDistance(int linkId) { @@ -435,3 +574,14 @@ private void RebuildPinIdToNodeIdMap() /// Result of attempting to create a link. public record LinkCreationResult(bool Success, string Message, Link? Link = null); + +/// +/// The part of a graph found by walking away from one node, as node and link identifiers. +/// +/// The nodes reached. +/// The links walked to reach them. +public record GraphReach(IReadOnlySet NodeIds, IReadOnlySet LinkIds) +{ + /// A reach containing nothing. + public static GraphReach Empty { get; } = new(new HashSet(), new HashSet()); +} diff --git a/ImGui.NodeEditor/NodeEditorRenderer.cs b/ImGui.NodeEditor/NodeEditorRenderer.cs index 8b4f703..765a367 100644 --- a/ImGui.NodeEditor/NodeEditorRenderer.cs +++ b/ImGui.NodeEditor/NodeEditorRenderer.cs @@ -24,6 +24,9 @@ public class NodeEditorRenderer /// How much of the editor a fitted graph is asked to fill, leaving a margin around it. private const float FitMargin = 0.9f; + /// How thick a highlighted node's outline is drawn, at the view's own scale. + private const float HighlightedNodeBorderThickness = 3.0f; + private readonly Dictionary lastKnownNodePositions = []; /// Pin rows collected while drawing one node, reused across nodes and frames. @@ -31,6 +34,18 @@ public class NodeEditorRenderer private readonly Dictionary lastKnownNodeDimensions = []; private readonly HashSet currentlyDraggedNodes = []; + /// Where each node was drawn this frame, in screen space. + private readonly Dictionary nodeScreenRects = []; + + /// Where each pin's circle was drawn this frame, in screen space. + private readonly Dictionary pinScreenPositions = []; + + /// The links drawn in the highlight colour this frame. + private readonly HashSet highlightedLinks = []; + + /// The nodes outlined in the highlight colour this frame. + private readonly HashSet highlightedNodes = []; + // Cached editor-to-screen transform: derived empirically from ImNodes // during Render() so it matches ImNodes' internal coordinate system exactly private Vector2 editorToScreenBase; @@ -45,6 +60,54 @@ public class NodeEditorRenderer /// public IReadOnlySet CurrentlyDraggedNodes => currentlyDraggedNodes; + /// + /// Whether hovering a node draws the links that meet it in the highlight colour. + /// + /// + /// Which links reach a node is the thing a dense graph hides: the lines cross, pass behind other + /// nodes, and leave at pins too close together to tell apart. Colouring them while the node is + /// under the pointer answers it without asking the user to click anything. + /// + public bool HighlightLinksOnNodeHover { get; set; } = true; + + /// + /// Whether hovering a node also highlights everything its value reaches: the nodes downstream of + /// it and the links leading to them. + /// + /// + /// Off by default. The reach of a node in a large graph is most of the graph, so highlighting it + /// is a question worth asking rather than a permanent state of the view. + /// + public bool HighlightDownstreamOnNodeHover { get; set; } + + /// + /// Whether the link under the pointer is drawn again over the nodes it passes behind. + /// + /// + /// ImNodes draws every link underneath the nodes, so following a hovered link across a crowded + /// graph means following a line that keeps disappearing. This redraws that one link on top, where + /// it can be traced from end to end. + /// + public bool DrawHoveredLinkOnTop { get; set; } = true; + + /// + /// The colour highlighted links and nodes are drawn in, or null to take ImNodes' own hovered-link + /// colour so the highlight follows the theme. + /// + public Vector4? HighlightColor { get; set; } + + /// The node the pointer was over as of the last frame drawn, if any. + public int? HoveredNodeId { get; private set; } + + /// The link the pointer was over as of the last frame drawn, if any. + public int? HoveredLinkId { get; private set; } + + /// The links drawn in the highlight colour this frame. + public IReadOnlySet HighlightedLinks => highlightedLinks; + + /// The nodes outlined in the highlight colour this frame. + public IReadOnlySet HighlightedNodes => highlightedNodes; + /// /// How large the graph is drawn, as a multiplier: 1 draws it at the engine's own scale. /// @@ -80,6 +143,13 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) // how big the editor is. zoomAnchor = editorSize * 0.5f; + // Hover is only answerable once the editor has been drawn, so what is highlighted this frame + // comes from the hover the last frame ended on. A frame's lag on a pointer that has to rest + // on a node to mean anything is not a lag anyone sees. + RefreshHighlights(engine); + nodeScreenRects.Clear(); + pinScreenPositions.Clear(); + bool scaled = !IsUnzoomed; ScaledStyle restore = default; if (scaled) @@ -88,18 +158,20 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) restore = ScaleImNodesStyle(Zoom); } + uint highlightColor = HighlightColorU32(); + ImNodes.BeginNodeEditor(); // Render all nodes foreach (Node node in engine.Nodes) { - RenderNode(engine, node); + RenderNode(engine, node, highlightColor); } // Render all links foreach (Link link in engine.Links) { - ImNodes.Link(link.Id, link.OutputPinId, link.InputPinId); + RenderLink(link, highlightColor); } // Cache the editor-to-screen transform while inside the editor context. @@ -109,6 +181,11 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) ImNodes.EndNodeEditor(); + // Asked while the answers are still about the frame just drawn, and before the style is put + // back, since the overlay is drawn at the sizes that frame used. + ReadHoverState(); + DrawHoveredLinkOverNodes(engine); + if (scaled) { RestoreImNodesStyle(restore); @@ -225,7 +302,7 @@ private readonly record struct ScaledStyle( /// /// Render a single node /// - private void RenderNode(NodeEditorEngine engine, Node node) + private void RenderNode(NodeEditorEngine engine, Node node, uint highlightColor) { pinRows.Clear(); // Apply engine position to ImNodes BEFORE rendering the node @@ -250,6 +327,16 @@ private void RenderNode(NodeEditorEngine engine, Node node) lastKnownNodePositions[node.Id] = viewPos; } + // ImNodes copies the outline colour and border thickness into the node as it begins it, so a + // highlighted node is styled around its own submission rather than by the style in force when + // the editor ends. + bool highlighted = highlightedNodes.Contains(node.Id); + if (highlighted) + { + ImNodes.PushColorStyle(ImNodesCol.NodeOutline, highlightColor); + ImNodes.PushStyleVar(ImNodesStyleVar.NodeBorderThickness, HighlightedNodeBorderThickness * Zoom); + } + ImNodes.BeginNode(node.Id); // Node title @@ -308,9 +395,181 @@ private void RenderNode(NodeEditorEngine engine, Node node) ImNodes.EndNode(); + if (highlighted) + { + ImNodes.PopStyleVar(); + ImNodes.PopColorStyle(); + } + PublishPinOffsets(engine, node); } + /// + /// Submit one link, in the highlight colour if it is one of the highlighted ones. + /// + /// + /// ImNodes captures a link's three colours — its own, its hovered one and its selected one — as + /// the link is submitted, so pushing them around this call colours this link and no other. All + /// three are pushed because a highlighted link that is also hovered or selected should stay the + /// highlight colour rather than reverting under the pointer. + /// + private void RenderLink(Link link, uint highlightColor) + { + bool highlighted = highlightedLinks.Contains(link.Id); + if (highlighted) + { + ImNodes.PushColorStyle(ImNodesCol.Link, highlightColor); + ImNodes.PushColorStyle(ImNodesCol.LinkHovered, highlightColor); + ImNodes.PushColorStyle(ImNodesCol.LinkSelected, highlightColor); + } + + ImNodes.Link(link.Id, link.OutputPinId, link.InputPinId); + + if (highlighted) + { + ImNodes.PopColorStyle(); + ImNodes.PopColorStyle(); + ImNodes.PopColorStyle(); + } + } + + /// + /// Work out what this frame draws in the highlight colour, from the hover the last frame ended on. + /// + private void RefreshHighlights(NodeEditorEngine engine) + { + highlightedLinks.Clear(); + highlightedNodes.Clear(); + + if (HoveredNodeId is not int hovered) + { + return; + } + + if (HighlightLinksOnNodeHover) + { + foreach (Link link in engine.GetNodeLinks(hovered)) + { + highlightedLinks.Add(link.Id); + } + } + + if (HighlightDownstreamOnNodeHover) + { + GraphReach reach = engine.GetDownstream(hovered); + + foreach (int linkId in reach.LinkIds) + { + highlightedLinks.Add(linkId); + } + + foreach (int nodeId in reach.NodeIds) + { + highlightedNodes.Add(nodeId); + } + } + } + + /// + /// The colour a highlight is drawn in: whatever was asked for, or ImNodes' hovered-link colour. + /// + private uint HighlightColorU32() => + HighlightColor is Vector4 color + ? ImGui.ColorConvertFloat4ToU32(color) + : ImNodes.GetStyle().Colors[(int)ImNodesCol.LinkHovered]; + + /// + /// Note what the pointer was over in the frame just drawn. + /// + /// + /// ImNodes only answers these once the editor has ended, which is why they describe the frame + /// that just finished rather than the one about to be built. + /// + private void ReadHoverState() + { + int nodeId = 0; + HoveredNodeId = ImNodes.IsNodeHovered(ref nodeId) ? nodeId : null; + + int linkId = 0; + HoveredLinkId = ImNodes.IsLinkHovered(ref linkId) ? linkId : null; + } + + /// + /// Draw the hovered link again, over the nodes ImNodes drew on top of it. + /// + /// + /// ImNodes puts every link in a channel below the nodes, and there is no way to lift one out of + /// it, so the link is drawn a second time on the foreground list — the only list that is + /// guaranteed to be composited after the editor's own. It is the same curve ImNodes draws: the + /// cubic bezier between the two pins, with control points a quarter of the straight-line distance + /// to either side, so the copy lies exactly over the original rather than beside it. + /// + /// Clipped to the editor, so a link whose ends are outside the visible area does not draw over + /// whatever sits next to it. + /// + /// + private void DrawHoveredLinkOverNodes(NodeEditorEngine engine) + { + if (!DrawHoveredLinkOnTop || HoveredLinkId is not int hoveredLink) + { + return; + } + + Link? link = engine.Links.FirstOrDefault(l => l.Id == hoveredLink); + if (link is null || + !pinScreenPositions.TryGetValue(link.OutputPinId, out Vector2 start) || + !pinScreenPositions.TryGetValue(link.InputPinId, out Vector2 end)) + { + return; + } + + ImNodesStylePtr style = ImNodes.GetStyle(); + uint color = style.Colors[(int)ImNodesCol.LinkHovered]; + float thickness = style.LinkThickness; + float segmentsPerLength = style.LinkLineSegmentsPerLength; + + float length = (end - start).Length(); + Vector2 control = new(0.25f * length, 0.0f); + int segments = Math.Max((int)(length * segmentsPerLength), 1); + + // The editor is the item the node editor just submitted, so its rectangle is the area to + // keep the overlay inside without the caller having to say where the editor is. + Vector2 clipMin = ImGui.GetItemRectMin(); + Vector2 clipMax = ImGui.GetItemRectMax(); + bool clip = clipMax.X > clipMin.X && clipMax.Y > clipMin.Y; + + ImDrawListPtr drawList = ImGui.GetForegroundDrawList(); + if (clip) + { + drawList.PushClipRect(clipMin, clipMax, true); + } + + drawList.AddBezierCubic(start, start + control, end - control, end, color, thickness, segments); + + if (clip) + { + drawList.PopClipRect(); + } + } + + /// + /// Where a node was drawn in the last frame, in screen space. + /// + /// The node. + /// The rectangle it occupied. + /// True when the node has been drawn at least once. + public bool TryGetNodeScreenRect(int nodeId, out ScreenRect rect) => + nodeScreenRects.TryGetValue(nodeId, out rect); + + /// + /// Where a pin's circle was drawn in the last frame, in screen space. + /// + /// The pin. + /// The point links to it meet. + /// True when the pin has been drawn at least once. + public bool TryGetPinScreenPosition(int pinId, out Vector2 position) => + pinScreenPositions.TryGetValue(pinId, out position); + /// /// Notes the vertical middle of the pin row just submitted, in screen space. /// @@ -335,9 +594,19 @@ private void PublishPinOffsets(NodeEditorEngine engine, Node node) Vector2 nodeScreenPos = ImNodes.GetNodeScreenSpacePos(node.Id); Vector2 nodeDimensions = ImNodes.GetNodeDimensions(node.Id); + nodeScreenRects[node.Id] = new ScreenRect(nodeScreenPos, nodeScreenPos + nodeDimensions); + + // Where ImNodes puts the pin circle itself: just outside the node's edge, level with the + // middle of its row. An overlay drawn between anything else would not meet the link it is + // covering. + float pinOffset = ImNodes.GetStyle().PinOffset; foreach ((int pinId, float middleY, bool isInput) in pinRows) { + pinScreenPositions[pinId] = new Vector2( + isInput ? nodeScreenPos.X - pinOffset : nodeScreenPos.X + nodeDimensions.X + pinOffset, + middleY); + // Inputs sit on the left edge and outputs on the right. Everything here is in the zoomed // space the view draws in, and the engine's lengths are not, so the offset is scaled back // the same way node dimensions are. @@ -535,7 +804,11 @@ public void RenderDebugOverlays(NodeEditorEngine engine, Vector2 editorAreaPos, return; } - ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + // The editor is a child window, and a child window's drawing is composited over its parent's, + // so an overlay on the parent's list would be underneath the very nodes it is describing — + // including the force and velocity vectors, which start at node centres. The foreground list + // is the one drawn after everything. + ImDrawListPtr drawList = ImGui.GetForegroundDrawList(); // Clip debug overlays to the editor area so they don't spill into adjacent panels drawList.PushClipRect(editorAreaPos, editorAreaPos + editorAreaSize, true); @@ -769,3 +1042,17 @@ private static float CalculateNodeContentWidth(Node node) return maxWidth + 20.0f; // 20px padding } } + +/// +/// A rectangle something was drawn in, in screen space. +/// +/// The top-left corner. +/// The bottom-right corner. +public readonly record struct ScreenRect(Vector2 Min, Vector2 Max) +{ + /// The rectangle's middle. + public Vector2 Centre => (Min + Max) * 0.5f; + + /// How much room the rectangle takes. + public Vector2 Size => Max - Min; +} diff --git a/README.md b/README.md index ef06696..af48046 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,9 @@ Node nodeA = factory.CreateNode(new Vector2(100, 100)); NodeEditorRenderer renderer = new(); NodeEditorInputHandler inputHandler = new(); renderer.Render(engine, editorSize); + +// Hovering a node colours the links that meet it; turn on the reach as well +renderer.HighlightDownstreamOnNodeHover = true; ``` ### Markdown Rendering @@ -614,12 +617,38 @@ Core node graph business logic. | `RemoveNode(int)` | `bool` | Remove a node | | `TryCreateLink(int, int)` | `LinkCreationResult` | Create a link between pins | | `RemoveLink(int)` | `bool` | Remove a link | +| `SetPinAllowsMultipleConnections(int, bool)` | `bool` | Change how many links a pin accepts | +| `GetOutgoingLinks(int)` | `IEnumerable` | The links leaving a node | +| `GetIncomingLinks(int)` | `IEnumerable` | The links arriving at a node | +| `GetDownstream(int)` | `GraphReach` | The nodes and links reached by following links forward | +| `GetUpstream(int)` | `GraphReach` | The nodes and links reached by following links backward | | `UpdatePhysics(float)` | `void` | Run physics simulation step | | `Clear()` | `void` | Remove all nodes and links | | `Nodes` | `IReadOnlyList` | All nodes | | `Links` | `IReadOnlyList` | All links | | `IsStable` | `bool` | Whether physics is stable | +An output pin fans out to as many inputs as want its value and an input pin takes one link, unless +a pin says otherwise through `[InputPin(AllowMultipleConnections = true)]`, +`[OutputPin(AllowMultipleConnections = false)]` or `SetPinAllowsMultipleConnections`. + +### `NodeEditorRenderer` + +Draws the graph with ImNodes, and answers what the pointer is over. + +| Name | Return Type | Description | +| ---- | ----------- | ----------- | +| `Render(NodeEditorEngine, Vector2)` | `void` | Draw the graph | +| `FitToView(NodeEditorEngine, Vector2)` | `bool` | Centre the graph and zoom to fit | +| `Zoom` | `float` | How large the graph is drawn | +| `HighlightLinksOnNodeHover` | `bool` | Colour the links meeting the hovered node (default on) | +| `HighlightDownstreamOnNodeHover` | `bool` | Also colour everything the hovered node reaches (default off) | +| `DrawHoveredLinkOnTop` | `bool` | Redraw the hovered link over the nodes it passes behind (default on) | +| `HighlightColor` | `Vector4?` | The highlight colour, or null for ImNodes' hovered-link colour | +| `HoveredNodeId` / `HoveredLinkId` | `int?` | What the pointer was over in the last frame | +| `TryGetNodeScreenRect(int, out ScreenRect)` | `bool` | Where a node was last drawn | +| `TryGetPinScreenPosition(int, out Vector2)` | `bool` | Where a pin was last drawn | + ### Node Graph Attributes | Attribute | Target | Description | diff --git a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs index d516ed7..53ad483 100644 --- a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs +++ b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs @@ -222,6 +222,10 @@ private void RenderControlsPanel() ImGui.SeparatorText("Physics Simulation"); RenderPhysicsControls(); + // Hover highlighting + ImGui.SeparatorText("Hover Highlighting"); + RenderHighlightControls(); + // Debug visualization toggle ImGui.Separator(); DemoProbe.Checkbox("Show Debug Visualization", ref showDebugVisualization); @@ -243,6 +247,31 @@ private void RenderControlsPanel() } } + /// + /// Draws the renderer's hover options, which are what a node under the pointer says about the + /// rest of the graph. + /// + private void RenderHighlightControls() + { + bool highlightLinks = renderer.HighlightLinksOnNodeHover; + if (DemoProbe.Checkbox("Highlight a hovered node's links", ref highlightLinks)) + { + renderer.HighlightLinksOnNodeHover = highlightLinks; + } + + bool highlightDownstream = renderer.HighlightDownstreamOnNodeHover; + if (DemoProbe.Checkbox("Highlight everything downstream of it", ref highlightDownstream)) + { + renderer.HighlightDownstreamOnNodeHover = highlightDownstream; + } + + bool hoveredLinkOnTop = renderer.DrawHoveredLinkOnTop; + if (DemoProbe.Checkbox("Draw a hovered link over the nodes", ref hoveredLinkOnTop)) + { + renderer.DrawHoveredLinkOnTop = hoveredLinkOnTop; + } + } + private void RenderDebugInformation() { ImGui.SeparatorText("Debug Information"); diff --git a/tests/ImGui.NodeEditor.Tests/AttributeBasedNodeFactoryTests.cs b/tests/ImGui.NodeEditor.Tests/AttributeBasedNodeFactoryTests.cs index 1acd333..550032c 100644 --- a/tests/ImGui.NodeEditor.Tests/AttributeBasedNodeFactoryTests.cs +++ b/tests/ImGui.NodeEditor.Tests/AttributeBasedNodeFactoryTests.cs @@ -274,6 +274,30 @@ public void CreateMethodNode_BuildsTheNodeAndRefusesAnUnregisteredMethod() Assert.ThrowsExactly(() => factory.CreateMethodNode(unregistered, Vector2.Zero)); } + /// + /// A pin's declared capacity is only worth declaring if it reaches the graph: the engine builds + /// pins from names, so without the factory carrying this across, an + /// [OutputPin(AllowMultipleConnections = false)] would be read and then dropped. + /// + [TestMethod] + public void CreateNode_CarriesEachPinsDeclaredConnectionCapacity() + { + AttributeBasedNodeFactory factory = Factory; + factory.RegisterNodeType(); + + Node node = factory.CreateNode(Vector2.Zero); + + Assert.IsTrue( + node.InputPins.Single(p => p.EffectiveDisplayName == "Any").AllowsMultipleConnections, + "An input declared to take several connections should."); + Assert.IsFalse( + node.OutputPins.Single(p => p.EffectiveDisplayName == "One").AllowsMultipleConnections, + "An output declared to take one connection should."); + Assert.IsTrue( + node.OutputPins.Single(p => p.EffectiveDisplayName == "Instance").AllowsMultipleConnections, + "The instance output is there to be chained onward, by as many nodes as want it."); + } + [TestMethod] public void GetNodeDefinition_ReturnsNullForAnythingUnregistered() { @@ -354,6 +378,16 @@ public sealed class SideEffectNode public string Message { get; set; } = string.Empty; } + [Node("Capacity")] + public sealed class CapacityNode + { + [InputPin("Any", AllowMultipleConnections = true)] + public double Any { get; set; } + + [OutputPin("One", AllowMultipleConnections = false)] + public double One { get; set; } + } + public sealed class NotANode { public int Value { get; set; } diff --git a/tests/ImGui.NodeEditor.Tests/HoverHighlightTests.cs b/tests/ImGui.NodeEditor.Tests/HoverHighlightTests.cs new file mode 100644 index 0000000..b743a7b --- /dev/null +++ b/tests/ImGui.NodeEditor.Tests/HoverHighlightTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor.Tests; + +using System; +using System.Linq; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App; +using ktsu.ImGui.App.Testing; +using ktsu.ImGui.NodeEditor; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Drives the renderer's hover highlighting through real frames, because hover is a thing ImNodes +/// only answers once a frame has been drawn: what is highlighted comes from where the pointer was +/// when the last frame ended, and neither half of that exists without running the frames. +/// +[TestClass] +public sealed class HoverHighlightTests +{ + private static readonly HarnessOptions Viewport = new() { Width = 1000, Height = 700 }; + + private readonly NodeEditorEngine engine = new(); + private readonly NodeEditorRenderer renderer = new(); + + private ImGuiAppHarness harness = null!; + + [TestCleanup] + public void TearDown() => harness?.Dispose(); + + private void Start() + { + harness = ImGuiAppHarness.Start( + new ImGuiAppConfig + { + Title = nameof(HoverHighlightTests), + OnRender = _ => renderer.Render(engine, ImGui.GetContentRegionAvail()), + SaveIniSettings = false, + }, + Viewport); + + harness.Step(3); + } + + /// Source → Middle → Target, drawn far enough apart that each can be pointed at alone. + private (Node Source, Node Middle, Node Target) AChain() + { + Node source = engine.CreateNode(new Vector2(80, 120), "Source", [], ["Value"]); + Node middle = engine.CreateNode(new Vector2(380, 120), "Middle", ["In"], ["Out"]); + Node target = engine.CreateNode(new Vector2(680, 120), "Target", ["In"], []); + engine.TryCreateLink(source.OutputPins[0].Id, middle.InputPins[0].Id); + engine.TryCreateLink(middle.OutputPins[0].Id, target.InputPins[0].Id); + return (source, middle, target); + } + + /// Puts the pointer over a node and runs the frame that notices it, plus the one that acts on it. + private void Hover(Node node) + { + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect rect), $"{node.Name} has not been drawn."); + harness.Mouse.MoveTo(rect.Centre.X, rect.Centre.Y); + harness.Step(2); + } + + [TestMethod] + public void HoveringANodeHighlightsTheLinksThatMeetIt() + { + (_, Node middle, _) = AChain(); + Start(); + + Hover(middle); + + Assert.AreEqual(middle.Id, renderer.HoveredNodeId); + Assert.HasCount(2, renderer.HighlightedLinks, "Both of the middle node's links should be highlighted."); + Assert.IsEmpty(renderer.HighlightedNodes, "Highlighting a node's links should not outline any node."); + } + + [TestMethod] + public void HoveringANodeHighlightsOnlyItsOwnLinks() + { + (Node source, _, _) = AChain(); + Start(); + + Hover(source); + + Assert.HasCount(1, renderer.HighlightedLinks, "Only the link leaving the source node should be highlighted."); + } + + [TestMethod] + public void MovingOffANodeDropsTheHighlight() + { + (_, Node middle, _) = AChain(); + Start(); + Hover(middle); + + harness.Mouse.MoveTo(Viewport.Width - 5, Viewport.Height - 5); + harness.Step(2); + + Assert.IsNull(renderer.HoveredNodeId); + Assert.IsEmpty(renderer.HighlightedLinks); + } + + [TestMethod] + public void HighlightingCanBeTurnedOff() + { + (_, Node middle, _) = AChain(); + renderer.HighlightLinksOnNodeHover = false; + Start(); + + Hover(middle); + + Assert.AreEqual(middle.Id, renderer.HoveredNodeId, "The node was still hovered, only the highlight was off."); + Assert.IsEmpty(renderer.HighlightedLinks); + } + + /// + /// The downstream option answers "what does this node end up affecting", so it reaches past the + /// nodes the hovered one touches and stops at what feeds it. + /// + [TestMethod] + public void HoveringANodeHighlightsEverythingDownstreamOfItWhenAsked() + { + (Node source, Node middle, Node target) = AChain(); + renderer.HighlightDownstreamOnNodeHover = true; + Start(); + + Hover(source); + + // Sorted before comparing: what is highlighted is a set, and it promises no order. + Assert.AreSequenceEqual([middle.Id, target.Id], renderer.HighlightedNodes.Order()); + Assert.HasCount(2, renderer.HighlightedLinks); + } + + [TestMethod] + public void TheDownstreamHighlightLeavesOutWhatFeedsTheHoveredNode() + { + (_, Node middle, Node target) = AChain(); + renderer.HighlightDownstreamOnNodeHover = true; + Start(); + + Hover(middle); + + Assert.AreSequenceEqual([target.Id], renderer.HighlightedNodes.Order()); + } + + [TestMethod] + public void NothingIsHighlightedDownstreamUnlessAsked() + { + (Node source, _, _) = AChain(); + Start(); + + Hover(source); + + Assert.IsEmpty(renderer.HighlightedNodes); + } + + /// + /// A long link with a node parked in the middle of it, which is the arrangement the hovered link + /// is supposed to be drawn out of: ImNodes puts every link under every node, so the middle of + /// this one is behind the blocker until something draws it again. + /// + private (Link Link, Node Blocker) ALinkPassingBehindANode() + { + Node source = engine.CreateNode(new Vector2(60, 300), "Source", [], ["Value"]); + Node target = engine.CreateNode(new Vector2(760, 300), "Target", ["In"], []); + Node blocker = engine.CreateNode(new Vector2(400, 300), "Blocker", ["A"], ["B"]); + LinkCreationResult created = engine.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id); + Assert.IsTrue(created.Success, created.Message); + + Start(); + + // Where the link runs is only known once it has been drawn, so the blocker is moved onto it + // afterwards rather than guessed at: a test that assumed the geometry would be testing its + // own arithmetic rather than the drawing. + Assert.IsTrue(renderer.TryGetPinScreenPosition(source.OutputPins[0].Id, out Vector2 start)); + Assert.IsTrue(renderer.TryGetPinScreenPosition(target.InputPins[0].Id, out Vector2 end)); + Assert.IsTrue(renderer.TryGetNodeScreenRect(blocker.Id, out ScreenRect blockerRect)); + + Vector2 shift = ((start + end) * 0.5f) - blockerRect.Centre; + engine.UpdateNodePosition(blocker.Id, engine.Nodes.First(n => n.Id == blocker.Id).Position + shift); + harness.Step(2); + + // Pointed at near the source end, where the link is in the open: ImNodes resolves a hovered + // node before a hovered link, so the pointer cannot ask about the part behind the blocker. + Assert.IsTrue(renderer.TryGetPinScreenPosition(source.OutputPins[0].Id, out start)); + Assert.IsTrue(renderer.TryGetPinScreenPosition(target.InputPins[0].Id, out end)); + Vector2 exposed = Vector2.Lerp(start, end, 0.12f); + harness.Mouse.MoveTo(exposed.X, exposed.Y); + + Assert.IsTrue( + harness.StepUntil(() => renderer.HoveredLinkId == created.Link!.Id, 5), + "The pointer was placed on the link and ImNodes did not report it as hovered."); + + return (created.Link!, blocker); + } + + /// How many pixels of a rectangle differ between two captures. + private static int PixelsDiffering(CapturedFrame before, CapturedFrame after, ScreenRect rect) + { + int minX = Math.Max((int)Math.Ceiling(rect.Min.X), 0); + int minY = Math.Max((int)Math.Ceiling(rect.Min.Y), 0); + int maxX = Math.Min((int)Math.Floor(rect.Max.X), before.Width - 1); + int maxY = Math.Min((int)Math.Floor(rect.Max.Y), before.Height - 1); + + int differing = 0; + for (int y = minY; y <= maxY; y++) + { + for (int x = minX; x <= maxX; x++) + { + if (before.GetPixel(x, y) != after.GetPixel(x, y)) + { + differing++; + } + } + } + + return differing; + } + + [TestMethod] + public void TheHoveredLinkIsDrawnOverTheNodeItPassesBehind() + { + renderer.DrawHoveredLinkOnTop = false; + (_, Node blocker) = ALinkPassingBehindANode(); + + Assert.IsTrue(renderer.TryGetNodeScreenRect(blocker.Id, out ScreenRect rect)); + CapturedFrame hidden = harness.Capture(); + + renderer.DrawHoveredLinkOnTop = true; + harness.Step(2); + CapturedFrame drawn = harness.Capture(); + + Assert.IsGreaterThan( + 0, + PixelsDiffering(hidden, drawn, rect), + "Nothing was drawn over the node the hovered link passes behind."); + } + + /// + /// The other half of the previous test: without the option, the node the link passes behind is + /// untouched, so what that test measures is the overlay and not the frame moving under it. + /// + [TestMethod] + public void TheHoveredLinkStaysBehindTheNodeWhenTheOptionIsOff() + { + renderer.DrawHoveredLinkOnTop = false; + (_, Node blocker) = ALinkPassingBehindANode(); + + Assert.IsTrue(renderer.TryGetNodeScreenRect(blocker.Id, out ScreenRect rect)); + CapturedFrame first = harness.Capture(); + harness.Step(2); + CapturedFrame second = harness.Capture(); + + Assert.AreEqual(0, PixelsDiffering(first, second, rect)); + } +} diff --git a/tests/ImGui.NodeEditor.Tests/NodeEditorEngineTests.cs b/tests/ImGui.NodeEditor.Tests/NodeEditorEngineTests.cs index 566ad60..1a1377a 100644 --- a/tests/ImGui.NodeEditor.Tests/NodeEditorEngineTests.cs +++ b/tests/ImGui.NodeEditor.Tests/NodeEditorEngineTests.cs @@ -140,6 +140,91 @@ public void TryCreateLink_RefusesASecondConnectionIntoOneInput() Assert.HasCount(1, engine.Links); } + /// + /// One value can feed as many consumers as want it, which is the whole point of a graph rather + /// than a chain. + /// + [TestMethod] + public void TryCreateLink_LetsOneOutputFeedSeveralInputs() + { + Node source = engine.CreateNode(new Vector2(0, 0), "Source", [], ["Value"]); + Node first = engine.CreateNode(new Vector2(300, 0), "First", ["Input"], []); + Node second = engine.CreateNode(new Vector2(300, 200), "Second", ["Input"], []); + Node third = engine.CreateNode(new Vector2(300, 400), "Third", ["Input"], []); + + LinkCreationResult[] results = + [ + engine.TryCreateLink(source.OutputPins[0].Id, first.InputPins[0].Id), + engine.TryCreateLink(source.OutputPins[0].Id, second.InputPins[0].Id), + engine.TryCreateLink(source.OutputPins[0].Id, third.InputPins[0].Id), + ]; + + Assert.IsTrue(results.All(r => r.Success), string.Join("; ", results.Select(r => r.Message))); + Assert.HasCount(3, engine.Links); + } + + [TestMethod] + public void TryCreateLink_RefusesTheSamePairTwice() + { + Node source = engine.CreateNode(new Vector2(0, 0), "Source", [], ["Value"]); + Node target = engine.CreateNode(new Vector2(300, 0), "Target", ["Input"], []); + + engine.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id); + LinkCreationResult result = engine.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id); + + Assert.IsFalse(result.Success); + Assert.HasCount(1, engine.Links); + } + + /// + /// An output that has been narrowed to one connection keeps the one it has and refuses the next, + /// which is how a declared [OutputPin(AllowMultipleConnections = false)] reads once the + /// factory has carried it onto the pin. + /// + [TestMethod] + public void TryCreateLink_RefusesASecondLinkFromAnOutputNarrowedToOne() + { + Node source = engine.CreateNode(new Vector2(0, 0), "Source", [], ["Value"]); + Node first = engine.CreateNode(new Vector2(300, 0), "First", ["Input"], []); + Node second = engine.CreateNode(new Vector2(300, 200), "Second", ["Input"], []); + + Assert.IsTrue(engine.SetPinAllowsMultipleConnections(source.OutputPins[0].Id, allowMultiple: false)); + engine.TryCreateLink(source.OutputPins[0].Id, first.InputPins[0].Id); + LinkCreationResult result = engine.TryCreateLink(source.OutputPins[0].Id, second.InputPins[0].Id); + + Assert.IsFalse(result.Success); + Assert.Contains("already connected", result.Message); + Assert.HasCount(1, engine.Links); + } + + [TestMethod] + public void TryCreateLink_AcceptsASecondLinkIntoAnInputThatAllowsThem() + { + Node first = engine.CreateNode(new Vector2(0, 0), "First", [], ["Value"]); + Node second = engine.CreateNode(new Vector2(0, 200), "Second", [], ["Value"]); + Node target = engine.CreateNode(new Vector2(300, 0), "Target", ["Any"], []); + + engine.SetPinAllowsMultipleConnections(target.InputPins[0].Id, allowMultiple: true); + engine.TryCreateLink(first.OutputPins[0].Id, target.InputPins[0].Id); + LinkCreationResult result = engine.TryCreateLink(second.OutputPins[0].Id, target.InputPins[0].Id); + + Assert.IsTrue(result.Success, result.Message); + Assert.HasCount(2, engine.Links); + } + + [TestMethod] + public void SetPinAllowsMultipleConnections_ReportsAPinItDoesNotHave() => + Assert.IsFalse(engine.SetPinAllowsMultipleConnections(9999, allowMultiple: true)); + + [TestMethod] + public void PinsDefaultToOneLinkIntoAnInputAndManyOutOfAnOutput() + { + Node node = engine.CreateNode(new Vector2(0, 0), "Node", ["In"], ["Out"]); + + Assert.IsFalse(node.InputPins[0].AllowsMultipleConnections); + Assert.IsTrue(node.OutputPins[0].AllowsMultipleConnections); + } + [TestMethod] public void TryCreateLink_RefusesANodeConnectedToItself() { @@ -373,4 +458,174 @@ public void InitializeWorldOriginToCentroid_RunsOverTheCurrentNodes() Assert.AreEqual(0f, engine.GravityCenter.X, 150f); Assert.AreEqual(0f, engine.GravityCenter.Y, 150f); } + + /// + /// The reach of a node is what its value ends up affecting, which is the question behind + /// highlighting everything downstream of the node under the pointer. + /// + [TestMethod] + public void GetDownstream_FollowsLinksForwardThroughTheWholeChain() + { + Node first = engine.CreateNode(new Vector2(0, 0), "First", [], ["Out"]); + Node second = engine.CreateNode(new Vector2(300, 0), "Second", ["In"], ["Out"]); + Node third = engine.CreateNode(new Vector2(600, 0), "Third", ["In"], []); + engine.TryCreateLink(first.OutputPins[0].Id, second.InputPins[0].Id); + engine.TryCreateLink(second.OutputPins[0].Id, third.InputPins[0].Id); + + GraphReach reach = engine.GetDownstream(first.Id); + + // A reach is a set, so it is sorted before being compared as a sequence: the walk makes no + // promise about the order it finds things in, and a test that assumed one would be asserting + // something the engine does not say. + Assert.AreSequenceEqual([second.Id, third.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + [TestMethod] + public void GetDownstream_LeavesOutWhatFeedsTheNode() + { + Node upstream = engine.CreateNode(new Vector2(0, 0), "Upstream", [], ["Out"]); + Node middle = engine.CreateNode(new Vector2(300, 0), "Middle", ["In"], ["Out"]); + Node downstream = engine.CreateNode(new Vector2(600, 0), "Downstream", ["In"], []); + engine.TryCreateLink(upstream.OutputPins[0].Id, middle.InputPins[0].Id); + engine.TryCreateLink(middle.OutputPins[0].Id, downstream.InputPins[0].Id); + + GraphReach reach = engine.GetDownstream(middle.Id); + + Assert.AreSequenceEqual([downstream.Id], reach.NodeIds.Order()); + Assert.HasCount(1, reach.LinkIds); + } + + [TestMethod] + public void GetDownstream_FollowsEveryBranchOfAFannedOutput() + { + Node source = engine.CreateNode(new Vector2(0, 0), "Source", [], ["Out"]); + Node left = engine.CreateNode(new Vector2(300, 0), "Left", ["In"], []); + Node right = engine.CreateNode(new Vector2(300, 200), "Right", ["In"], []); + engine.TryCreateLink(source.OutputPins[0].Id, left.InputPins[0].Id); + engine.TryCreateLink(source.OutputPins[0].Id, right.InputPins[0].Id); + + GraphReach reach = engine.GetDownstream(source.Id); + + Assert.AreSequenceEqual([left.Id, right.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + /// + /// A cycle terminates the walk rather than running it forever, and the node it leads back to is + /// reported: a node that feeds itself round a loop is downstream of itself. + /// + [TestMethod] + public void GetDownstream_WalksACycleOnce() + { + Node first = engine.CreateNode(new Vector2(0, 0), "First", ["In"], ["Out"]); + Node second = engine.CreateNode(new Vector2(300, 0), "Second", ["In"], ["Out"]); + engine.TryCreateLink(first.OutputPins[0].Id, second.InputPins[0].Id); + engine.TryCreateLink(second.OutputPins[0].Id, first.InputPins[0].Id); + + GraphReach reach = engine.GetDownstream(first.Id); + + Assert.AreSequenceEqual([first.Id, second.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + [TestMethod] + public void GetDownstream_OfANodeThatFeedsNothingIsEmpty() + { + Node node = engine.CreateNode(new Vector2(0, 0), "Alone", ["In"], ["Out"]); + + GraphReach reach = engine.GetDownstream(node.Id); + + Assert.IsEmpty(reach.NodeIds); + Assert.IsEmpty(reach.LinkIds); + } + + [TestMethod] + public void GetUpstream_FollowsLinksBackwardThroughTheWholeChain() + { + Node first = engine.CreateNode(new Vector2(0, 0), "First", [], ["Out"]); + Node second = engine.CreateNode(new Vector2(300, 0), "Second", ["In"], ["Out"]); + Node third = engine.CreateNode(new Vector2(600, 0), "Third", ["In"], []); + engine.TryCreateLink(first.OutputPins[0].Id, second.InputPins[0].Id); + engine.TryCreateLink(second.OutputPins[0].Id, third.InputPins[0].Id); + + GraphReach reach = engine.GetUpstream(third.Id); + + Assert.AreSequenceEqual([first.Id, second.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + [TestMethod] + public void GetUpstream_LeavesOutWhatTheNodeFeeds() + { + Node upstream = engine.CreateNode(new Vector2(0, 0), "Upstream", [], ["Out"]); + Node middle = engine.CreateNode(new Vector2(300, 0), "Middle", ["In"], ["Out"]); + Node downstream = engine.CreateNode(new Vector2(600, 0), "Downstream", ["In"], []); + engine.TryCreateLink(upstream.OutputPins[0].Id, middle.InputPins[0].Id); + engine.TryCreateLink(middle.OutputPins[0].Id, downstream.InputPins[0].Id); + + GraphReach reach = engine.GetUpstream(middle.Id); + + Assert.AreSequenceEqual([upstream.Id], reach.NodeIds.Order()); + Assert.HasCount(1, reach.LinkIds); + } + + /// + /// Both sources of a node that adds two values are upstream of it, which is the shape a fan-in + /// makes and the mirror of the fanned output the downstream walk follows. + /// + [TestMethod] + public void GetUpstream_FollowsEveryBranchOfAFanIn() + { + Node left = engine.CreateNode(new Vector2(0, 0), "Left", [], ["Out"]); + Node right = engine.CreateNode(new Vector2(0, 200), "Right", [], ["Out"]); + Node sum = engine.CreateNode(new Vector2(300, 0), "Sum", ["A", "B"], []); + engine.TryCreateLink(left.OutputPins[0].Id, sum.InputPins[0].Id); + engine.TryCreateLink(right.OutputPins[0].Id, sum.InputPins[1].Id); + + GraphReach reach = engine.GetUpstream(sum.Id); + + Assert.AreSequenceEqual([left.Id, right.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + [TestMethod] + public void GetUpstream_WalksACycleOnce() + { + Node first = engine.CreateNode(new Vector2(0, 0), "First", ["In"], ["Out"]); + Node second = engine.CreateNode(new Vector2(300, 0), "Second", ["In"], ["Out"]); + engine.TryCreateLink(first.OutputPins[0].Id, second.InputPins[0].Id); + engine.TryCreateLink(second.OutputPins[0].Id, first.InputPins[0].Id); + + GraphReach reach = engine.GetUpstream(first.Id); + + Assert.AreSequenceEqual([first.Id, second.Id], reach.NodeIds.Order()); + Assert.HasCount(2, reach.LinkIds); + } + + [TestMethod] + public void GetUpstream_OfANodeNothingFeedsIsEmpty() + { + Node node = engine.CreateNode(new Vector2(0, 0), "Alone", ["In"], ["Out"]); + + GraphReach reach = engine.GetUpstream(node.Id); + + Assert.IsEmpty(reach.NodeIds); + Assert.IsEmpty(reach.LinkIds); + } + + [TestMethod] + public void GetOutgoingAndIncomingLinks_SplitTheLinksAtANodeByDirection() + { + Node source = engine.CreateNode(new Vector2(0, 0), "Source", [], ["Out"]); + Node middle = engine.CreateNode(new Vector2(300, 0), "Middle", ["In"], ["Out"]); + Node target = engine.CreateNode(new Vector2(600, 0), "Target", ["In"], []); + engine.TryCreateLink(source.OutputPins[0].Id, middle.InputPins[0].Id); + engine.TryCreateLink(middle.OutputPins[0].Id, target.InputPins[0].Id); + + Assert.HasCount(1, engine.GetIncomingLinks(middle.Id).ToList()); + Assert.HasCount(1, engine.GetOutgoingLinks(middle.Id).ToList()); + Assert.IsEmpty(engine.GetIncomingLinks(source.Id).ToList()); + Assert.IsEmpty(engine.GetOutgoingLinks(target.Id).ToList()); + } }