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
50 changes: 48 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<PackageVersion Include="ktsu.Invoker" Version="1.2.37" />
<PackageVersion Include="ktsu.ScopedAction" Version="1.1.43" />
<PackageVersion Include="ktsu.TextFilter" Version="1.7.1" />
<PackageVersion Include="ktsu.UndoRedo" Version="2.0.3" />
<PackageVersion Include="ktsu.Keybinding" Version="2.0.3" />
<PackageVersion Include="Markdig" Version="1.4.0" />
<PackageVersion Include="Moq" Version="4.21.0" />
<PackageVersion Include="Silk.NET" Version="2.23.0" />
Expand Down
24 changes: 24 additions & 0 deletions ImGui.NodeEditor/AttributeBasedNodeFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ public AttributeBasedNodeFactory(NodeEditorEngine engine)

private void OnCleared(object? sender, EventArgs e) => bindings.Clear();

/// <summary>
/// Reattach a binding to a node that was removed and has been put back.
/// </summary>
/// <param name="binding">The binding the node had, as <see cref="GetBinding"/> reported it before the removal.</param>
/// <returns>True if the engine holds a node with the binding's id and the binding was attached.</returns>
/// <remarks>
/// Removing a node drops its binding, because an id that names no node must not keep an instance
/// alive. <see cref="NodeEditorHistory"/> undoes a removal by restoring the node under the same
/// id, and this is how the instance comes back with it — the same instance, so its pin accessors,
/// which the engine restores alongside the node, still read and write the object this binding
/// names.
/// </remarks>
public bool RestoreBinding(NodeBinding binding)
{
Ensure.NotNull(binding);
if (!engine.Nodes.Any(n => n.Id == binding.NodeId))
{
return false;
}

bindings[binding.NodeId] = binding;
return true;
}

/// <summary>
/// Registers a type as a node definition by scanning its attributes.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion ImGui.NodeEditor/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale.
A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale. Every edit can be undone through a ktsu.UndoRedo history, keyboard commands follow a ktsu.Keybinding keymap, dragged nodes can snap to the grid, and labelled comment boxes group regions of the graph and carry their nodes when moved.
69 changes: 69 additions & 0 deletions ImGui.NodeEditor/DomainModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,72 @@ public enum PinDirection
Output
}

/// <summary>
/// Says which pin's value changed, from what, to what, and as part of which edit.
/// </summary>
/// <param name="PinId">The pin whose value was written.</param>
/// <param name="OldValue">What it held before.</param>
/// <param name="NewValue">What it holds now.</param>
/// <param name="EditGesture">
/// The continuous edit the write belongs to, or null for a write that stands alone. See
/// <see cref="NodeEditorEngine.SetPinValue(int, object?, long?)"/>.
/// </param>
public sealed class PinValueChangedEventArgs(int PinId, object? OldValue, object? NewValue, long? EditGesture) : EventArgs
{
/// <summary>Gets the pin whose value was written.</summary>
public int PinId { get; } = PinId;

/// <summary>Gets what it held before.</summary>
public object? OldValue { get; } = OldValue;

/// <summary>Gets what it holds now.</summary>
public object? NewValue { get; } = NewValue;

/// <summary>Gets the continuous edit the write belongs to, or null for a write that stands alone.</summary>
public long? EditGesture { get; } = EditGesture;
}

/// <summary>
/// A labelled rectangle drawn behind the nodes, used to name and organise a region of the graph.
/// </summary>
/// <param name="Id">The comment box's identifier, unique among comment boxes. It shares no space with node ids.</param>
/// <param name="Title">The label drawn in its title bar.</param>
/// <param name="Position">Its top-left corner, in the same space as node positions.</param>
/// <param name="Size">Its width and height, in the same space as node dimensions.</param>
/// <param name="Color">
/// The colour it is filled with, or null to take one from the editor's theme. The title bar and
/// border are drawn from the same colour at a higher opacity.
/// </param>
/// <remarks>
/// A comment box does not own the nodes inside it. What it contains is decided by geometry every
/// time it is asked — a node lying wholly within its rectangle is in it — so dragging a node out of
/// a box takes it out, and nothing has to be kept in step when a node is added, removed or moved by
/// the layout. <see cref="NodeEditorEngine.MoveCommentBox"/> is what carries the contents along.
/// <para>
/// Comment boxes take no part in the force-directed layout. They are not bodies, and nothing pushes
/// a node out of one or pulls it in.
/// </para>
/// </remarks>
public sealed record CommentBox(int Id, string Title, Vector2 Position, Vector2 Size, Vector4? Color = null)
{
/// <summary>The bottom-right corner.</summary>
public Vector2 Max => Position + Size;

/// <summary>
/// Whether a rectangle lies wholly inside this box.
/// </summary>
/// <param name="position">The rectangle's top-left corner.</param>
/// <param name="size">Its width and height.</param>
/// <returns>True when no part of it is outside.</returns>
public bool Contains(Vector2 position, Vector2 size) =>
position.X >= Position.X && position.Y >= Position.Y &&
position.X + size.X <= Max.X && position.Y + size.Y <= Max.Y;
}

/// <summary>
/// One node's move from where a gesture found it to where the gesture left it.
/// </summary>
/// <param name="NodeId">The node.</param>
/// <param name="From">Where it was before.</param>
/// <param name="To">Where it is after.</param>
public readonly record struct NodeMove(int NodeId, Vector2 From, Vector2 To);
2 changes: 2 additions & 0 deletions ImGui.NodeEditor/ImGui.NodeEditor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<PackageReference Include="Hexa.NET.ImGui" />
<PackageReference Include="Hexa.NET.ImNodes" />
<PackageReference Include="ktsu.Semantics.Quantities" />
<PackageReference Include="ktsu.UndoRedo" />
<PackageReference Include="ktsu.Keybinding" />
<PackageReference Include="Polyfill" PrivateAssets="all" />
</ItemGroup>

Expand Down
211 changes: 211 additions & 0 deletions ImGui.NodeEditor/NodeEditorCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.NodeEditor;

using System;
using System.Collections.Generic;
using System.Linq;
using Hexa.NET.ImGui;
using ktsu.Keybinding.Core.Contracts;
using ktsu.Keybinding.Core.Models;

/// <summary>
/// The node editor's keyboard commands, in the form <c>ktsu.Keybinding</c> registers and binds.
/// </summary>
/// <remarks>
/// A <see cref="NodeEditorInputHandler"/> given an <see cref="IKeybindingService"/> reads each of
/// these commands' chords from the service's active profile, so the host's own keymap — whatever
/// profile the user picked, whatever they rebound — decides which keys drive the graph. Without a
/// service the handler uses <see cref="DefaultChords"/>, plus Backspace for delete and Ctrl+Shift+Z
/// for redo, which a profile holding one chord per command cannot express.
/// </remarks>
public static class NodeEditorCommands
{
/// <summary>The category the commands are registered under.</summary>
public const string Category = "Node Editor";

/// <summary>Undo the last change to the graph.</summary>
public const string Undo = "nodeeditor.undo";

/// <summary>Redo the last change undone.</summary>
public const string Redo = "nodeeditor.redo";

/// <summary>Delete the selected nodes and links.</summary>
public const string Delete = "nodeeditor.delete";

/// <summary>Duplicate the selected nodes.</summary>
public const string Duplicate = "nodeeditor.duplicate";

/// <summary>Every command, ready to register.</summary>
public static IReadOnlyList<Command> All { get; } =
[
new(Undo, "Undo", "Undo the last change to the graph", Category),
new(Redo, "Redo", "Redo the last change undone", Category),
new(Delete, "Delete Selection", "Delete the selected nodes and links", Category),
new(Duplicate, "Duplicate Selection", "Duplicate the selected nodes", Category),
];

/// <summary>The chord each command is bound to unless the user says otherwise.</summary>
public static IReadOnlyDictionary<string, string> DefaultChords { get; } = new Dictionary<string, string>
{
[Undo] = "Ctrl+Z",
[Redo] = "Ctrl+Y",
[Delete] = "Delete",
[Duplicate] = "Ctrl+D",
};

/// <summary>
/// Register the commands, and bind each one that has no chord yet to its default.
/// </summary>
/// <param name="registry">Where the host's commands are registered, such as <c>KeybindingManager.Commands</c>.</param>
/// <param name="keybindings">The host's keybindings, such as <c>KeybindingManager.Keybindings</c>.</param>
/// <param name="bindDefaultChords">False to register the commands and leave every chord to the host.</param>
/// <returns>How many chords were bound.</returns>
/// <remarks>
/// Safe to call on every start-up: a command already registered is left as it is, and a chord the
/// user has already bound — loaded from their saved profile — is not overwritten. Chords are
/// bound in the active profile, so there has to be one for any to be bound.
/// </remarks>
public static int Register(ICommandRegistry registry, IKeybindingService keybindings, bool bindDefaultChords = true)
{
Ensure.NotNull(registry);
Ensure.NotNull(keybindings);

foreach (Command command in All.Where(c => !registry.IsCommandRegistered(c.Id)))
{
registry.RegisterCommand(command);
}

if (!bindDefaultChords)
{
return 0;
}

int bound = 0;
foreach ((string commandId, string chord) in DefaultChords)
{
if (!keybindings.HasChordBinding(commandId) && keybindings.BindChord(commandId, keybindings.ParseChord(chord)))
{
bound++;
}
}

return bound;
}
}

/// <summary>
/// Answers whether a <c>ktsu.Keybinding</c> chord was pressed this frame, in ImGui's terms.
/// </summary>
/// <remarks>
/// A chord matches when its modifiers are exactly the ones held — so Ctrl+Z does not fire for
/// Ctrl+Shift+Z — every other key in it is down, and at least one of them went down this frame. Key
/// repeat is ignored, so holding a chord fires it once.
/// </remarks>
internal static class KeyChordMatcher
{
private static readonly Dictionary<string, ImGuiKey> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["ESC"] = ImGuiKey.Escape,
["RETURN"] = ImGuiKey.Enter,
["DEL"] = ImGuiKey.Delete,
["INS"] = ImGuiKey.Insert,
["UP"] = ImGuiKey.UpArrow,
["DOWN"] = ImGuiKey.DownArrow,
["LEFT"] = ImGuiKey.LeftArrow,
["RIGHT"] = ImGuiKey.RightArrow,
["ARROWUP"] = ImGuiKey.UpArrow,
["ARROWDOWN"] = ImGuiKey.DownArrow,
["ARROWLEFT"] = ImGuiKey.LeftArrow,
["ARROWRIGHT"] = ImGuiKey.RightArrow,
["PGUP"] = ImGuiKey.PageUp,
["PGDN"] = ImGuiKey.PageDown,
["SPACEBAR"] = ImGuiKey.Space,
};

public static bool IsPressed(Chord chord)
{
bool ctrl = false;
bool alt = false;
bool shift = false;
bool meta = false;
List<ImGuiKey> keys = [];

foreach (string name in chord.Notes.Select(note => note.ToString()))
{
switch (name)
{
case "CTRL" or "CONTROL":
ctrl = true;
break;

case "ALT":
alt = true;
break;

case "SHIFT":
shift = true;
break;

case "META" or "WIN" or "WINDOWS" or "CMD" or "COMMAND" or "SUPER":
meta = true;
break;

default:
if (!TryMapKey(name, out ImGuiKey key))
{
// A key ImGui has no name for can never be pressed, so neither can the chord.
return false;
}

keys.Add(key);
break;
}
}

if (keys.Count == 0)
{
return false;
}

ImGuiIOPtr io = ImGui.GetIO();
if (io.KeyCtrl != ctrl || io.KeyAlt != alt || io.KeyShift != shift || io.KeySuper != meta)
{
return false;
}

return keys.All(ImGui.IsKeyDown) && keys.Any(key => ImGui.IsKeyPressed(key, repeat: false));
}

/// <summary>Find the ImGui key a note names.</summary>
/// <param name="name">The note's name, upper-cased as <c>ktsu.Keybinding</c> stores it.</param>
/// <param name="key">The key.</param>
/// <returns>True if ImGui has such a key.</returns>
public static bool TryMapKey(string name, out ImGuiKey key)
{
if (name.Length == 1 && name[0] is >= 'A' and <= 'Z')
{
key = (ImGuiKey)((int)ImGuiKey.A + (name[0] - 'A'));
return true;
}

if (name.Length == 1 && name[0] is >= '0' and <= '9')
{
key = (ImGuiKey)((int)ImGuiKey.Key0 + (name[0] - '0'));
return true;
}

if (Aliases.TryGetValue(name, out key))
{
return true;
}

// Everything else by ImGui's own name — DELETE, BACKSPACE, F5, PAGEUP, COMMA — but never a
// modifier, a mouse button or one of the range markers, none of which is a key to press.
return Enum.TryParse(name, ignoreCase: true, out key)
&& key > ImGuiKey.NamedKeyBegin
&& key < ImGuiKey.GamepadStart
&& key is not (ImGuiKey.LeftCtrl or ImGuiKey.RightCtrl or ImGuiKey.LeftShift or ImGuiKey.RightShift
or ImGuiKey.LeftAlt or ImGuiKey.RightAlt or ImGuiKey.LeftSuper or ImGuiKey.RightSuper);
}
}
Loading
Loading