Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 27 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` and `GetDownstream` 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<string?, string>?` — 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.
Expand All @@ -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/<Demo>.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`,
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 33 additions & 2 deletions ImGui.NodeEditor/AttributeBasedNodeFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand All @@ -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;
}

/// <summary>
/// Carry each declared pin's connection capacity onto the pin the engine just created.
/// </summary>
/// <param name="definition">The definition the node was created from.</param>
/// <param name="node">The created node.</param>
/// <remarks>
/// The engine creates pins from names alone, so without this an
/// <c>[OutputPin(AllowMultipleConnections = false)]</c> 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.
/// </remarks>
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<PinDefinition> definitions, List<Pin> pins)
{
for (int i = 0; i < definitions.Count && i < pins.Count; i++)
{
engine.SetPinAllowsMultipleConnections(pins[i].Id, definitions[i].AllowMultipleConnections);
}
}

/// <summary>
Expand Down
27 changes: 26 additions & 1 deletion ImGui.NodeEditor/DomainModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,42 @@ int InputPinId
/// <summary>
/// Represents a pin on a node
/// </summary>
/// <param name="Id">The pin's identifier, unique across every pin in the graph.</param>
/// <param name="Direction">Whether the pin takes a connection in or sends one out.</param>
/// <param name="Name">The pin's name.</param>
/// <param name="DisplayName">What to show instead of <paramref name="Name"/>, when they differ.</param>
/// <param name="AllowMultipleConnections">
/// How many links may meet this pin, or null to take the default for its direction. See
/// <see cref="AllowsMultipleConnections"/>.
/// </param>
public record Pin(
int Id,
PinDirection Direction,
string Name,
string? DisplayName = null
string? DisplayName = null,
bool? AllowMultipleConnections = null
)
{
/// <summary>
/// Gets the name to display in the UI, preferring DisplayName over Name
/// </summary>
public string EffectiveDisplayName => DisplayName ?? Name;

/// <summary>
/// Whether more than one link may meet this pin.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// Either default is overridden by giving <see cref="AllowMultipleConnections"/> a value, which
/// is how a declared <c>[InputPin(AllowMultipleConnections = true)]</c> or
/// <c>[OutputPin(AllowMultipleConnections = false)]</c> reaches the graph.
/// </para>
/// </remarks>
public bool AllowsMultipleConnections => AllowMultipleConnections ?? (Direction == PinDirection.Output);
};

/// <summary>
Expand Down
133 changes: 131 additions & 2 deletions ImGui.NodeEditor/NodeEditorEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -295,6 +306,113 @@ public IEnumerable<Link> GetNodeLinks(int nodeId)
node.OutputPins.Any(p => p.Id == l.OutputPinId));
}

/// <summary>
/// Change how many links a pin accepts, overriding the default for its direction.
/// </summary>
/// <param name="pinId">The pin to change.</param>
/// <param name="allowMultiple">True to let the pin take any number of links, false for one.</param>
/// <returns>True if the pin was found.</returns>
/// <remarks>
/// Links already made are left alone: narrowing a pin that is connected twice does not
/// disconnect either link, it only refuses the next one.
/// </remarks>
public bool SetPinAllowsMultipleConnections(int pinId, bool allowMultiple)
{
foreach (Node node in nodes)
{
if (TrySetPinCapacity(node.InputPins, pinId, allowMultiple) ||
TrySetPinCapacity(node.OutputPins, pinId, allowMultiple))
{
return true;
}
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

return false;
}

private static bool TrySetPinCapacity(List<Pin> 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;
}

/// <summary>Get the links leaving a node through its output pins.</summary>
/// <param name="nodeId">The node.</param>
/// <returns>Its outgoing links, or nothing if there is no such node.</returns>
public IEnumerable<Link> 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));
}

/// <summary>Get the links arriving at a node through its input pins.</summary>
/// <param name="nodeId">The node.</param>
/// <returns>Its incoming links, or nothing if there is no such node.</returns>
public IEnumerable<Link> 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));
}

/// <summary>
/// Everything a node's value reaches: the nodes found by following links forward from it, and
/// the links walked to get there.
/// </summary>
/// <param name="nodeId">The node to start from.</param>
/// <returns>The reach, which is empty when the node has no outgoing links or does not exist.</returns>
/// <remarks>
/// 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.
/// </remarks>
public GraphReach GetDownstream(int nodeId)
{
// 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<int> reachedNodes = [];
HashSet<int> walkedLinks = [];
HashSet<int> visited = [nodeId];
Queue<int> pending = new();
pending.Enqueue(nodeId);

while (pending.Count > 0)
{
int current = pending.Dequeue();

foreach (Link link in GetOutgoingLinks(current))
{
walkedLinks.Add(link.Id);

if (!pinIdToNodeId.TryGetValue(link.InputPinId, out int targetId))
{
continue;
}

reachedNodes.Add(targetId);

if (visited.Add(targetId))
{
pending.Enqueue(targetId);
}
}
}

return new GraphReach(reachedNodes, walkedLinks);
}

/// <summary>Calculate the distance between two connected nodes.</summary>
public float? GetLinkDistance(int linkId)
{
Expand Down Expand Up @@ -435,3 +553,14 @@ private void RebuildPinIdToNodeIdMap()

/// <summary>Result of attempting to create a link.</summary>
public record LinkCreationResult(bool Success, string Message, Link? Link = null);

/// <summary>
/// The part of a graph found by walking away from one node, as node and link identifiers.
/// </summary>
/// <param name="NodeIds">The nodes reached.</param>
/// <param name="LinkIds">The links walked to reach them.</param>
public record GraphReach(IReadOnlySet<int> NodeIds, IReadOnlySet<int> LinkIds)
{
/// <summary>A reach containing nothing.</summary>
public static GraphReach Empty { get; } = new(new HashSet<int>(), new HashSet<int>());
}
Loading
Loading